newshound 0.1.0 → 0.2.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.
@@ -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 = ""
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.report
61
+ job_data = que_reporter.report
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 "" unless text
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
@@ -2,6 +2,13 @@
2
2
 
3
3
  module Newshound
4
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
+
5
12
  def generate_report
6
13
  [
7
14
  {
@@ -33,9 +40,9 @@ module Newshound
33
40
  end
34
41
 
35
42
  def job_counts_by_type
36
- return {} unless defined?(::Que::Job)
37
-
38
- ::Que::Job
43
+ return {} unless job_source
44
+
45
+ job_source
39
46
  .group(:job_class)
40
47
  .group(:error_count)
41
48
  .count
@@ -76,16 +83,19 @@ module Newshound
76
83
  end
77
84
 
78
85
  def queue_statistics
79
- return default_stats unless defined?(::Que::Job)
80
-
86
+ return default_stats unless job_source
87
+
88
+ current_time = Time.now
89
+ beginning_of_day = Date.today.to_time
90
+
81
91
  {
82
- ready: ::Que::Job.where(finished_at: nil, expired_at: nil).where("run_at <= ?", Time.current).count,
83
- scheduled: ::Que::Job.where(finished_at: nil, expired_at: nil).where("run_at > ?", Time.current).count,
84
- failed: ::Que::Job.where.not(error_count: 0).where(finished_at: nil).count,
85
- finished_today: ::Que::Job.where("finished_at >= ?", Date.current.beginning_of_day).count
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
86
96
  }
87
97
  rescue StandardError => e
88
- Rails.logger.error "Failed to fetch Que statistics: #{e.message}"
98
+ logger.error "Failed to fetch Que statistics: #{e.message}"
89
99
  default_stats
90
100
  end
91
101
 
@@ -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.0"
4
+ VERSION = "0.2.0"
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
data/newshound.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/newshound/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "newshound"
7
+ spec.version = Newshound::VERSION
8
+ spec.authors = ["salbanez"]
9
+ spec.email = ["salbanez@example.com"]
10
+
11
+ spec.summary = "Real-time web UI banner for monitoring Que jobs and exception tracking"
12
+ spec.description = "Newshound displays exceptions and job statuses in a collapsible banner for authorized users in your Rails app"
13
+ spec.homepage = "https://github.com/salbanez/newshound"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
24
+ end
25
+ end
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ # Runtime dependencies
31
+ spec.add_dependency "rails", ">= 6.0"
32
+ spec.add_dependency "que", ">= 1.0"
33
+ spec.add_dependency "exception-track", ">= 0.1"
34
+ end