newshound 0.1.1 → 0.2.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.
@@ -24,6 +24,14 @@ module Newshound
24
24
  *format_exceptions
25
25
  ]
26
26
  end
27
+ alias_method :report, :generate_report
28
+
29
+ # Returns data formatted for the banner UI
30
+ def banner_data
31
+ {
32
+ exceptions: recent_exceptions.map { |exception| format_exception_for_banner(exception) }
33
+ }
34
+ end
27
35
 
28
36
  private
29
37
 
@@ -53,29 +61,100 @@ module Newshound
53
61
  end
54
62
 
55
63
  def format_exception_text(exception, number)
64
+ details = parse_exception_details(exception)
65
+
56
66
  <<~TEXT
57
- *#{number}. #{exception.title || exception.exception_class}*
67
+ *#{number}. #{exception_title(exception)}*
58
68
  • *Time:* #{exception.created_at.strftime('%I:%M %p')}
59
- • *Controller:* #{exception.controller_name}##{exception.action_name}
60
- • *Count:* #{exception_count(exception)}
61
- #{format_message(exception)}
69
+ #{format_controller(details)}
70
+ #{format_message(exception, details)}
62
71
  TEXT
63
72
  end
64
73
 
65
- def format_message(exception)
66
- return "" unless exception.message.present?
67
-
68
- message = exception.message.truncate(100)
74
+ def exception_title(exception)
75
+ if exception.respond_to?(:title) && exception.title.present?
76
+ exception.title
77
+ elsif exception.respond_to?(:exception_class) && exception.exception_class.present?
78
+ exception.exception_class
79
+ else
80
+ "Unknown Exception"
81
+ end
82
+ end
83
+
84
+ def parse_exception_details(exception)
85
+ return {} unless exception.respond_to?(:body) && exception.body.present?
86
+
87
+ JSON.parse(exception.body)
88
+ rescue JSON::ParserError
89
+ {}
90
+ end
91
+
92
+ def format_controller(details)
93
+ return String.new unless details["controller_name"] && details["action_name"]
94
+
95
+ "• *Controller:* #{details['controller_name']}##{details['action_name']}\n"
96
+ end
97
+
98
+ def format_message(exception, details = nil)
99
+ details ||= parse_exception_details(exception)
100
+
101
+ # Try to get message from different sources
102
+ message = if details["message"].present?
103
+ details["message"]
104
+ elsif exception.respond_to?(:message) && exception.message.present?
105
+ exception.message
106
+ end
107
+
108
+ return String.new unless message.present?
109
+
110
+ message = message.to_s.truncate(100)
69
111
  "• *Message:* `#{message}`"
70
112
  end
71
113
 
72
114
  def exception_count(exception)
73
115
  return 0 unless exception_source
74
116
 
75
- exception_source
76
- .where(exception_class: exception.exception_class)
77
- .where("created_at >= ?", time_range.ago)
78
- .count
117
+ # Use title for exception-track, exception_class for other systems
118
+ if exception.respond_to?(:title) && exception.title.present?
119
+ exception_source
120
+ .where(title: exception.title)
121
+ .where("created_at >= ?", time_range.ago)
122
+ .count
123
+ elsif exception.respond_to?(:exception_class)
124
+ exception_source
125
+ .where(exception_class: exception.exception_class)
126
+ .where("created_at >= ?", time_range.ago)
127
+ .count
128
+ else
129
+ 0
130
+ end
131
+ end
132
+
133
+ def format_exception_for_banner(exception)
134
+ details = parse_exception_details(exception)
135
+
136
+ # Extract message
137
+ message = if details["message"].present?
138
+ details["message"].to_s
139
+ elsif exception.respond_to?(:message) && exception.message.present?
140
+ exception.message.to_s
141
+ else
142
+ String.new
143
+ end
144
+
145
+ # Extract location
146
+ location = if details["controller_name"] && details["action_name"]
147
+ "#{details['controller_name']}##{details['action_name']}"
148
+ else
149
+ String.new
150
+ end
151
+
152
+ {
153
+ title: exception_title(exception),
154
+ message: message.truncate(100),
155
+ location: location,
156
+ time: exception.created_at.strftime('%I:%M %p')
157
+ }
79
158
  end
80
159
 
81
160
  def no_exceptions_block
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Newshound
4
+ module Middleware
5
+ class BannerInjector
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ status, headers, response = @app.call(env)
12
+
13
+ # Only inject into HTML responses
14
+ return [status, headers, response] unless html_response?(headers)
15
+ return [status, headers, response] unless status == 200
16
+
17
+ # Check authorization
18
+ controller = env['action_controller.instance']
19
+ return [status, headers, response] unless controller
20
+ return [status, headers, response] unless Newshound::Authorization.authorized?(controller)
21
+
22
+ # Get banner HTML
23
+ banner_html = generate_banner_html
24
+
25
+ # Inject banner after <body> tag
26
+ new_response = inject_banner(response, banner_html)
27
+
28
+ # Update Content-Length header
29
+ if headers['Content-Length']
30
+ headers['Content-Length'] = new_response.bytesize.to_s
31
+ end
32
+
33
+ [status, headers, [new_response]]
34
+ end
35
+
36
+ private
37
+
38
+ def html_response?(headers)
39
+ content_type = headers['Content-Type']
40
+ content_type && content_type.include?('text/html')
41
+ end
42
+
43
+ def inject_banner(response, banner_html)
44
+ body = response_body(response)
45
+
46
+ # Inject after <body> tag
47
+ body.sub(/(<body[^>]*>)/i, "\\1\n#{banner_html}")
48
+ end
49
+
50
+ def response_body(response)
51
+ body = String.new
52
+ response.each { |part| body << part }
53
+ body
54
+ end
55
+
56
+ def generate_banner_html
57
+ exception_reporter = Newshound::ExceptionReporter.new
58
+ que_reporter = Newshound::QueReporter.new
59
+
60
+ exception_data = exception_reporter.banner_data
61
+ job_data = que_reporter.banner_data
62
+
63
+ # Generate HTML from template
64
+ render_banner(exception_data, job_data)
65
+ end
66
+
67
+ def render_banner(exception_data, job_data)
68
+ <<~HTML
69
+ <div id="newshound-banner" class="newshound-banner newshound-collapsed">
70
+ #{render_styles}
71
+ <div class="newshound-header" onclick="document.getElementById('newshound-banner').classList.toggle('newshound-collapsed')">
72
+ <span class="newshound-title">
73
+ 🐕 Newshound
74
+ #{summary_badge(exception_data, job_data)}
75
+ </span>
76
+ <span class="newshound-toggle">▼</span>
77
+ </div>
78
+ <div class="newshound-content">
79
+ #{render_exceptions(exception_data)}
80
+ #{render_jobs(job_data)}
81
+ </div>
82
+ </div>
83
+ HTML
84
+ end
85
+
86
+ def render_styles
87
+ <<~CSS
88
+ <style>
89
+ .newshound-banner {
90
+ position: fixed;
91
+ top: 0;
92
+ left: 0;
93
+ right: 0;
94
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
95
+ color: white;
96
+ z-index: 10000;
97
+ box-shadow: 0 2px 10px rgba(0,0,0,0.3);
98
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
99
+ font-size: 14px;
100
+ }
101
+ .newshound-header {
102
+ padding: 12px 20px;
103
+ cursor: pointer;
104
+ display: flex;
105
+ justify-content: space-between;
106
+ align-items: center;
107
+ user-select: none;
108
+ }
109
+ .newshound-title {
110
+ font-weight: 600;
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 10px;
114
+ }
115
+ .newshound-badge {
116
+ display: inline-block;
117
+ padding: 2px 8px;
118
+ border-radius: 12px;
119
+ font-size: 12px;
120
+ font-weight: 600;
121
+ background: rgba(255,255,255,0.2);
122
+ }
123
+ .newshound-badge.error {
124
+ background: #ef4444;
125
+ }
126
+ .newshound-badge.warning {
127
+ background: #f59e0b;
128
+ }
129
+ .newshound-badge.success {
130
+ background: #10b981;
131
+ }
132
+ .newshound-toggle {
133
+ transition: transform 0.3s;
134
+ }
135
+ .newshound-banner.newshound-collapsed .newshound-toggle {
136
+ transform: rotate(-90deg);
137
+ }
138
+ .newshound-content {
139
+ max-height: 400px;
140
+ overflow-y: auto;
141
+ border-top: 1px solid rgba(255,255,255,0.2);
142
+ transition: max-height 0.3s ease-out;
143
+ }
144
+ .newshound-banner.newshound-collapsed .newshound-content {
145
+ max-height: 0;
146
+ overflow: hidden;
147
+ border-top: none;
148
+ }
149
+ .newshound-section {
150
+ padding: 15px 20px;
151
+ border-bottom: 1px solid rgba(255,255,255,0.1);
152
+ }
153
+ .newshound-section:last-child {
154
+ border-bottom: none;
155
+ }
156
+ .newshound-section-title {
157
+ font-weight: 600;
158
+ margin-bottom: 10px;
159
+ font-size: 15px;
160
+ }
161
+ .newshound-item {
162
+ background: rgba(255,255,255,0.1);
163
+ padding: 10px;
164
+ margin-bottom: 8px;
165
+ border-radius: 6px;
166
+ font-size: 13px;
167
+ }
168
+ .newshound-item:last-child {
169
+ margin-bottom: 0;
170
+ }
171
+ .newshound-item-title {
172
+ font-weight: 600;
173
+ margin-bottom: 4px;
174
+ }
175
+ .newshound-item-detail {
176
+ opacity: 0.9;
177
+ font-size: 12px;
178
+ }
179
+ .newshound-grid {
180
+ display: grid;
181
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
182
+ gap: 10px;
183
+ }
184
+ .newshound-stat {
185
+ background: rgba(255,255,255,0.1);
186
+ padding: 12px;
187
+ border-radius: 6px;
188
+ text-align: center;
189
+ }
190
+ .newshound-stat-value {
191
+ font-size: 24px;
192
+ font-weight: 700;
193
+ display: block;
194
+ }
195
+ .newshound-stat-label {
196
+ font-size: 11px;
197
+ opacity: 0.8;
198
+ text-transform: uppercase;
199
+ letter-spacing: 0.5px;
200
+ }
201
+ </style>
202
+ CSS
203
+ end
204
+
205
+ def summary_badge(exception_data, job_data)
206
+ exception_count = exception_data[:exceptions]&.length || 0
207
+ failed_jobs = job_data.dig(:queue_stats, :failed) || 0
208
+
209
+ if exception_count > 0 || failed_jobs > 10
210
+ badge_class = "error"
211
+ text = "#{exception_count} exceptions, #{failed_jobs} failed jobs"
212
+ elsif failed_jobs > 5
213
+ badge_class = "warning"
214
+ text = "#{failed_jobs} failed jobs"
215
+ else
216
+ badge_class = "success"
217
+ text = "All clear"
218
+ end
219
+
220
+ %(<span class="newshound-badge #{badge_class}">#{text}</span>)
221
+ end
222
+
223
+ def render_exceptions(data)
224
+ exceptions = data[:exceptions] || []
225
+
226
+ if exceptions.empty?
227
+ 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>)
228
+ end
229
+
230
+ items = exceptions.take(5).map do |ex|
231
+ <<~HTML
232
+ <div class="newshound-item">
233
+ <div class="newshound-item-title">#{escape_html(ex[:title])}</div>
234
+ <div class="newshound-item-detail">
235
+ #{escape_html(ex[:message])} • #{escape_html(ex[:location])} • #{escape_html(ex[:time])}
236
+ </div>
237
+ </div>
238
+ HTML
239
+ end.join
240
+
241
+ <<~HTML
242
+ <div class="newshound-section">
243
+ <div class="newshound-section-title">⚠️ Recent Exceptions (#{exceptions.length})</div>
244
+ #{items}
245
+ </div>
246
+ HTML
247
+ end
248
+
249
+ def render_jobs(data)
250
+ stats = data[:queue_stats] || {}
251
+
252
+ <<~HTML
253
+ <div class="newshound-section">
254
+ <div class="newshound-section-title">📊 Job Queue Status</div>
255
+ <div class="newshound-grid">
256
+ <div class="newshound-stat">
257
+ <span class="newshound-stat-value">#{stats[:ready_to_run] || 0}</span>
258
+ <span class="newshound-stat-label">Ready</span>
259
+ </div>
260
+ <div class="newshound-stat">
261
+ <span class="newshound-stat-value">#{stats[:scheduled] || 0}</span>
262
+ <span class="newshound-stat-label">Scheduled</span>
263
+ </div>
264
+ <div class="newshound-stat">
265
+ <span class="newshound-stat-value">#{stats[:failed] || 0}</span>
266
+ <span class="newshound-stat-label">Failed</span>
267
+ </div>
268
+ <div class="newshound-stat">
269
+ <span class="newshound-stat-value">#{stats[:completed_today] || 0}</span>
270
+ <span class="newshound-stat-label">Completed Today</span>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ HTML
275
+ end
276
+
277
+ def escape_html(text)
278
+ return String.new unless text.present?
279
+ text.to_s
280
+ .gsub('&', '&amp;')
281
+ .gsub('<', '&lt;')
282
+ .gsub('>', '&gt;')
283
+ .gsub('"', '&quot;')
284
+ .gsub("'", '&#39;')
285
+ end
286
+ end
287
+ end
288
+ end
@@ -22,6 +22,21 @@ module Newshound
22
22
  queue_health_section
23
23
  ].compact
24
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
25
40
 
26
41
  private
27
42
 
@@ -42,21 +57,33 @@ module Newshound
42
57
  def job_counts_by_type
43
58
  return {} unless job_source
44
59
 
45
- job_source
46
- .group(:job_class)
47
- .group(:error_count)
48
- .count
49
- .each_with_object({}) do |((job_class, error_count), count), hash|
50
- hash[job_class] ||= { success: 0, failed: 0, total: 0 }
51
-
52
- if error_count.zero?
53
- hash[job_class][:success] += count
54
- else
55
- hash[job_class][:failed] += count
56
- end
57
-
58
- hash[job_class][:total] += count
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
59
80
  end
81
+
82
+ hash[job_class][:total] += count
83
+ end
84
+ rescue StandardError => e
85
+ logger.error "Failed to fetch job counts: #{e.message}"
86
+ {}
60
87
  end
61
88
 
62
89
  def format_job_counts(counts)
@@ -85,20 +112,27 @@ module Newshound
85
112
  def queue_statistics
86
113
  return default_stats unless job_source
87
114
 
88
- current_time = Time.now
89
- beginning_of_day = Date.today.to_time
115
+ conn = ActiveRecord::Base.connection
116
+ current_time = conn.quote(Time.now)
117
+ beginning_of_day = conn.quote(Date.today.to_time)
90
118
 
91
119
  {
92
- ready: job_source.where(finished_at: nil, expired_at: nil).where("run_at <= ?", current_time).count,
93
- scheduled: job_source.where(finished_at: nil, expired_at: nil).where("run_at > ?", current_time).count,
94
- failed: job_source.where.not(error_count: 0).where(finished_at: nil).count,
95
- finished_today: job_source.where("finished_at >= ?", beginning_of_day).count
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}")
96
124
  }
97
125
  rescue StandardError => e
98
126
  logger.error "Failed to fetch Que statistics: #{e.message}"
99
127
  default_stats
100
128
  end
101
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
+
102
136
  def default_stats
103
137
  { ready: 0, scheduled: 0, failed: 0, finished_today: 0 }
104
138
  end
@@ -4,59 +4,55 @@ require "rails/railtie"
4
4
 
5
5
  module Newshound
6
6
  class Railtie < Rails::Railtie
7
+ # Register middleware to inject banner
8
+ initializer "newshound.middleware" do |app|
9
+ if Newshound.configuration.enabled
10
+ app.middleware.use Newshound::Middleware::BannerInjector
11
+ end
12
+ end
13
+
7
14
  rake_tasks do
8
15
  namespace :newshound do
9
- desc "Send daily report immediately"
10
- task report_now: :environment do
11
- puts "Sending Newshound daily report..."
12
- Newshound.report!
13
- puts "Report sent successfully!"
14
- rescue StandardError => e
15
- puts "Failed to send report: #{e.message}"
16
- end
17
-
18
- desc "Schedule daily report job"
19
- task schedule: :environment do
20
- puts "Scheduling Newshound daily report..."
21
- Newshound::Scheduler.run_now!
22
- puts "Job enqueued successfully!"
23
- end
24
-
25
16
  desc "Show current configuration"
26
17
  task config: :environment do
27
18
  config = Newshound.configuration
28
19
  puts "Newshound Configuration:"
29
20
  puts " Enabled: #{config.enabled}"
30
- puts " Slack Webhook: #{config.slack_webhook_url.present? ? '[CONFIGURED]' : '[NOT SET]'}"
31
- puts " Slack Channel: #{config.slack_channel}"
32
- puts " Report Time: #{config.report_time}"
33
21
  puts " Exception Limit: #{config.exception_limit}"
34
- puts " Time Zone: #{config.time_zone}"
35
- puts " Valid: #{config.valid?}"
22
+ puts " Authorized Roles: #{config.authorized_roles.join(', ')}"
23
+ puts " Current User Method: #{config.current_user_method}"
24
+ puts " Custom Authorization: #{config.authorization_block.present? ? 'Yes' : 'No'}"
25
+ end
26
+
27
+ desc "Test exception reporter"
28
+ task test_exceptions: :environment do
29
+ reporter = Newshound::ExceptionReporter.new
30
+ data = reporter.report
31
+ 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]}"
35
+ end
36
36
  end
37
- end
38
- end
39
37
 
40
- initializer "newshound.configure_que_scheduler" do
41
- ActiveSupport.on_load(:active_record) do
42
- if defined?(::Que::Scheduler)
43
- require_relative "scheduler"
44
-
45
- # Add our job to the que-scheduler configuration
46
- schedule = Newshound::Scheduler.schedule_daily_report
47
-
48
- Rails.logger.info "Newshound: Scheduled daily report at #{Newshound.configuration.report_time}"
49
- else
50
- Rails.logger.warn "Newshound: que-scheduler not found. Daily reports will need to be triggered manually."
38
+ desc "Test job reporter"
39
+ task test_jobs: :environment do
40
+ reporter = Newshound::QueReporter.new
41
+ data = reporter.report
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)}"
51
47
  end
52
48
  end
53
49
  end
54
50
 
55
51
  config.after_initialize do
56
52
  if Newshound.configuration.valid?
57
- Rails.logger.info "Newshound initialized and ready to report!"
53
+ Rails.logger.info "Newshound initialized - banner will be shown to authorized users"
58
54
  else
59
- Rails.logger.warn "Newshound is not properly configured. Please check your configuration."
55
+ Rails.logger.warn "Newshound is disabled"
60
56
  end
61
57
  end
62
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Newshound
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/newshound.rb CHANGED
@@ -4,9 +4,8 @@ require_relative "newshound/version"
4
4
  require_relative "newshound/configuration"
5
5
  require_relative "newshound/exception_reporter"
6
6
  require_relative "newshound/que_reporter"
7
- require_relative "newshound/slack_notifier"
8
- require_relative "newshound/daily_report_job"
9
- require_relative "newshound/scheduler"
7
+ require_relative "newshound/authorization"
8
+ require_relative "newshound/middleware/banner_injector"
10
9
  require_relative "newshound/railtie" if defined?(Rails)
11
10
 
12
11
  module Newshound
@@ -21,46 +20,9 @@ module Newshound
21
20
  yield(configuration)
22
21
  end
23
22
 
24
- def report!
25
- slack_notifier = SlackNotifier.new
26
-
27
- exception_report = ExceptionReporter.new.generate_report
28
- que_report = QueReporter.new.generate_report
29
-
30
- message = format_daily_report(exception_report, que_report)
31
- slack_notifier.post(message)
32
- end
33
-
34
- private
35
-
36
- def format_daily_report(exception_report, que_report)
37
- {
38
- blocks: [
39
- {
40
- type: "header",
41
- text: {
42
- type: "plain_text",
43
- text: "🐕 Daily Newshound Report",
44
- emoji: true
45
- }
46
- },
47
- {
48
- type: "section",
49
- text: {
50
- type: "mrkdwn",
51
- text: "*Date:* #{Date.current.strftime('%B %d, %Y')}"
52
- }
53
- },
54
- {
55
- type: "divider"
56
- },
57
- *exception_report,
58
- {
59
- type: "divider"
60
- },
61
- *que_report
62
- ]
63
- }
23
+ # Allow setting custom authorization logic
24
+ def authorize_with(&block)
25
+ configuration.authorize_with(&block)
64
26
  end
65
27
  end
66
28
  end