diff --git a/lib/generators/newshound/install/templates/newshound.rb b/lib/generators/newshound/install/templates/newshound.rb index e8ede9e..4309457 100644 --- a/lib/generators/newshound/install/templates/newshound.rb +++ b/lib/generators/newshound/install/templates/newshound.rb @@ -29,6 +29,28 @@ # Default is :current_user config.current_user_method = :current_user + # Links for banner items + # Configure paths so banner items link to your exception/job dashboards. + # Use :id in show paths to interpolate the record ID. + # + # config.exception_links = { + # index: "/errors", + # show: "/errors/:id" + # } + # + # config.job_links = { + # index: "/background_jobs", + # show: "/background_jobs/jobs/:id", + # scheduled: "/background_jobs/scheduled", + # failed: "/background_jobs/failed", + # completed: "/background_jobs/completed" + # } + # + # config.warning_links = { + # index: "/warnings", + # show: "/warnings/:id" + # } + # Custom authorization logic: # If the default role-based authorization doesn't fit your needs, # you can provide a custom authorization block: diff --git a/lib/newshound/configuration.rb b/lib/newshound/configuration.rb index 9cd3852..c38f728 100644 --- a/lib/newshound/configuration.rb +++ b/lib/newshound/configuration.rb @@ -4,7 +4,8 @@ module Newshound class Configuration attr_accessor :exception_limit, :enabled, :authorized_roles, :current_user_method, :authorization_block, :exception_source, - :warning_source, :warning_limit, :job_source + :warning_source, :warning_limit, :job_source, + :exception_links, :job_links, :warning_links def initialize @exception_limit = 10 @@ -16,6 +17,9 @@ def initialize @warning_source = nil @warning_limit = 10 @job_source = nil + @exception_links = {} + @job_links = {} + @warning_links = {} end # Allow custom authorization logic diff --git a/lib/newshound/exceptions/base.rb b/lib/newshound/exceptions/base.rb index 347bd4c..69350c4 100644 --- a/lib/newshound/exceptions/base.rb +++ b/lib/newshound/exceptions/base.rb @@ -31,7 +31,7 @@ def format_for_report(exception, number) # Formats an exception for banner UI display # # @param exception [Object] Exception record from the tracking system - # @return [Hash] Hash with keys: :title, :message, :location, :time + # @return [Hash] Hash with keys: :id, :title, :message, :location, :time def format_for_banner(exception) raise NotImplementedError, "#{self.class} must implement #format_for_banner" end diff --git a/lib/newshound/exceptions/exception_track.rb b/lib/newshound/exceptions/exception_track.rb index 5b7c57b..083020b 100644 --- a/lib/newshound/exceptions/exception_track.rb +++ b/lib/newshound/exceptions/exception_track.rb @@ -23,6 +23,7 @@ def format_for_banner(exception) details = parse_exception_details(exception) { + id: exception.try(:id), title: details[:title], message: details[:message].truncate(100), location: details[:location], diff --git a/lib/newshound/exceptions/solid_errors.rb b/lib/newshound/exceptions/solid_errors.rb index 2c0f6ff..8b1ee96 100644 --- a/lib/newshound/exceptions/solid_errors.rb +++ b/lib/newshound/exceptions/solid_errors.rb @@ -23,6 +23,7 @@ def format_for_banner(exception) details = parse_exception_details(exception) { + id: exception.try(:error)&.id || exception.try(:id), title: details[:title], message: details[:message].truncate(100), location: details[:location], diff --git a/lib/newshound/middleware/banner_injector.rb b/lib/newshound/middleware/banner_injector.rb index cfb1c5f..2f9a3d0 100644 --- a/lib/newshound/middleware/banner_injector.rb +++ b/lib/newshound/middleware/banner_injector.rb @@ -276,6 +276,20 @@ def render_styles text-transform: uppercase; letter-spacing: 0.5px; } + .newshound-link { + color: inherit; + text-decoration: none; + } + .newshound-link:hover { + text-decoration: underline; + } + a.newshound-stat { + color: inherit; + text-decoration: none; + } + a.newshound-stat:hover { + background: rgba(255,255,255,0.2); + } CSS end @@ -309,23 +323,11 @@ def render_exceptions(data) return %(
✅ Exceptions
No exceptions in the last 24 hours
) end - items = exceptions.take(5).map do |ex| - <<~HTML -
-
#{escape_html(ex[:title])}
-
- #{escape_html(ex[:message])} • #{escape_html(ex[:location])} • #{escape_html(ex[:time])} -
-
- HTML - end.join - - <<~HTML -
-
⚠️ Recent Exceptions (#{exceptions.length})
- #{items} -
- HTML + render_item_section( + items: exceptions, + links: Newshound.configuration.exception_links, + title: "⚠️ Recent Exceptions (#{exceptions.length})" + ) end def render_warnings(data) @@ -333,53 +335,81 @@ def render_warnings(data) return "" if warnings.empty? - items = warnings.take(5).map do |w| - <<~HTML -
-
#{escape_html(w[:title])}
-
- #{escape_html(w[:message])} • #{escape_html(w[:location])} • #{escape_html(w[:time])} -
+ render_item_section( + items: warnings, + links: Newshound.configuration.warning_links, + title: "⚠️ Warnings (#{warnings.length})" + ) + end + + def render_item_section(items:, links:, title:) + rendered_items = items.take(5).map do |item| + item_content = <<~HTML +
#{escape_html(item[:title])}
+
+ #{escape_html(item[:message])} • #{escape_html(item[:location])} • #{escape_html(item[:time])}
HTML + + if links[:show] && item[:id] + url = links[:show].gsub(":id", item[:id].to_s) + %(
#{item_content}
) + else + %(
#{item_content}
) + end end.join + title_html = if links[:index] + %(#{title}) + else + title + end + <<~HTML
-
⚠️ Warnings (#{warnings.length})
- #{items} +
#{title_html}
+ #{rendered_items}
HTML end def render_jobs(data) stats = data[:queue_stats] || {} + links = Newshound.configuration.job_links + + section_title = "📊 Job Queue Status" + title_html = if links[:index] + %(#{section_title}) + else + section_title + end <<~HTML
-
📊 Job Queue Status
+
#{title_html}
-
- #{stats[:ready_to_run] || 0} - Ready -
-
- #{stats[:scheduled] || 0} - Scheduled -
-
- #{stats[:failed] || 0} - Failed -
-
- #{stats[:completed_today] || 0} - Completed Today -
+ #{render_job_stat(stats[:ready_to_run] || 0, "Ready", links[:index])} + #{render_job_stat(stats[:scheduled] || 0, "Scheduled", links[:scheduled])} + #{render_job_stat(stats[:failed] || 0, "Failed", links[:failed])} + #{render_job_stat(stats[:completed_today] || 0, "Completed Today", links[:completed])}
HTML end + def render_job_stat(value, label, link) + content = <<~HTML + #{value} + #{label} + HTML + + if link + %(#{content}) + else + %(
#{content}
) + end + end + def escape_html(text) return +"" unless text.present? text.to_s diff --git a/lib/newshound/warnings/base.rb b/lib/newshound/warnings/base.rb index 530d533..c3af873 100644 --- a/lib/newshound/warnings/base.rb +++ b/lib/newshound/warnings/base.rb @@ -33,7 +33,7 @@ def format_for_report(warning, number) # Formats a warning for banner UI display # # @param warning [Object] Warning record from the data source - # @return [Hash] Hash with keys: :title, :message, :location, :time + # @return [Hash] Hash with keys: :id, :title, :message, :location, :time def format_for_banner(warning) raise NotImplementedError, "#{self.class} must implement #format_for_banner" end diff --git a/spec/newshound/configuration_spec.rb b/spec/newshound/configuration_spec.rb new file mode 100644 index 0000000..2da2c9d --- /dev/null +++ b/spec/newshound/configuration_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.describe Newshound::Configuration do + subject(:config) { described_class.new } + + describe "link configuration" do + describe "#exception_links" do + it "defaults to an empty hash" do + expect(config.exception_links).to eq({}) + end + + it "accepts index and show keys" do + config.exception_links = { + index: "/errors", + show: "/errors/:id" + } + + expect(config.exception_links[:index]).to eq("/errors") + expect(config.exception_links[:show]).to eq("/errors/:id") + end + end + + describe "#job_links" do + it "defaults to an empty hash" do + expect(config.job_links).to eq({}) + end + + it "accepts index, show, scheduled, failed, and completed keys" do + config.job_links = { + index: "/background_jobs", + show: "/background_jobs/jobs/:id", + scheduled: "/background_jobs/scheduled", + failed: "/background_jobs/failed", + completed: "/background_jobs/completed" + } + + expect(config.job_links[:index]).to eq("/background_jobs") + expect(config.job_links[:show]).to eq("/background_jobs/jobs/:id") + expect(config.job_links[:scheduled]).to eq("/background_jobs/scheduled") + expect(config.job_links[:failed]).to eq("/background_jobs/failed") + expect(config.job_links[:completed]).to eq("/background_jobs/completed") + end + end + + describe "#warning_links" do + it "defaults to an empty hash" do + expect(config.warning_links).to eq({}) + end + + it "accepts index and show keys" do + config.warning_links = { + index: "/warnings", + show: "/warnings/:id" + } + + expect(config.warning_links[:index]).to eq("/warnings") + expect(config.warning_links[:show]).to eq("/warnings/:id") + end + end + end +end diff --git a/spec/newshound/exceptions/exception_track_spec.rb b/spec/newshound/exceptions/exception_track_spec.rb index ebdfed5..db82579 100644 --- a/spec/newshound/exceptions/exception_track_spec.rb +++ b/spec/newshound/exceptions/exception_track_spec.rb @@ -88,6 +88,7 @@ let(:exception) do double( "exception", + id: 42, title: "ActiveRecord::RecordNotFound", created_at: Time.new(2025, 10, 21, 14, 30, 0), body: '{"controller_name":"UsersController","action_name":"show","message":"Record not found"}', @@ -99,12 +100,14 @@ allow(exception).to receive(:respond_to?).with(:title).and_return(true) allow(exception).to receive(:respond_to?).with(:body).and_return(true) allow(exception).to receive(:respond_to?).with(:message).and_return(false) + allow(exception).to receive(:try) { |method| exception.public_send(method) } end it "formats exception for banner UI" do result = adapter.format_for_banner(exception) expect(result).to eq({ + id: 42, title: "ActiveRecord::RecordNotFound", message: "Record not found", location: "UsersController#show", @@ -112,9 +115,16 @@ }) end + it "includes the record id" do + result = adapter.format_for_banner(exception) + + expect(result[:id]).to eq(42) + end + it "handles exceptions without controller info" do exception_without_controller = double( "exception", + id: nil, title: "ArgumentError", created_at: Time.new(2025, 10, 21, 14, 30, 0), body: '{"message":"Invalid argument"}', @@ -122,6 +132,7 @@ ) allow(exception_without_controller).to receive(:respond_to?).with(:title).and_return(true) allow(exception_without_controller).to receive(:respond_to?).with(:body).and_return(true) + allow(exception_without_controller).to receive(:try) { |method| exception_without_controller.public_send(method) } result = adapter.format_for_banner(exception_without_controller) @@ -132,6 +143,7 @@ long_message = "a" * 150 exception_with_long_message = double( "exception", + id: nil, title: "Error", created_at: Time.new(2025, 10, 21, 14, 30, 0), body: "{\"message\":\"#{long_message}\"}", @@ -139,6 +151,7 @@ ) allow(exception_with_long_message).to receive(:respond_to?).with(:title).and_return(true) allow(exception_with_long_message).to receive(:respond_to?).with(:body).and_return(true) + allow(exception_with_long_message).to receive(:try) { |method| exception_with_long_message.public_send(method) } result = adapter.format_for_banner(exception_with_long_message) diff --git a/spec/newshound/exceptions/solid_errors_spec.rb b/spec/newshound/exceptions/solid_errors_spec.rb index a136e5a..8eeead8 100644 --- a/spec/newshound/exceptions/solid_errors_spec.rb +++ b/spec/newshound/exceptions/solid_errors_spec.rb @@ -123,6 +123,7 @@ let(:error_record) do double( "error", + id: 99, exception_class: "ActiveRecord::RecordNotFound", message: "Record not found" ) @@ -131,6 +132,7 @@ let(:exception) do double( "occurrence", + id: 7, created_at: Time.new(2025, 10, 21, 14, 30, 0), context: {"controller" => "UsersController", "action" => "show"}, respond_to?: true, @@ -141,12 +143,14 @@ before do allow(exception).to receive(:respond_to?).with(:context).and_return(true) allow(exception).to receive(:try).with(:error).and_return(error_record) + allow(exception).to receive(:try).with(:id).and_return(7) end it "formats exception for banner UI" do result = adapter.format_for_banner(exception) expect(result).to eq({ + id: 99, title: "ActiveRecord::RecordNotFound", message: "Record not found", location: "UsersController#show", @@ -154,15 +158,48 @@ }) end + it "uses the error record id for linking" do + result = adapter.format_for_banner(exception) + + expect(result[:id]).to eq(99) + end + + it "falls back to occurrence id when error has no id" do + error_without_id = double( + "error", + id: nil, + exception_class: "StandardError", + message: "msg" + ) + + occurrence = double( + "occurrence", + id: 7, + created_at: Time.new(2025, 10, 21, 14, 30, 0), + context: {}, + respond_to?: true, + error: error_without_id + ) + allow(occurrence).to receive(:respond_to?).with(:context).and_return(true) + allow(occurrence).to receive(:try).with(:error).and_return(error_without_id) + allow(occurrence).to receive(:try).with(:id).and_return(7) + + result = adapter.format_for_banner(occurrence) + + expect(result[:id]).to eq(7) + end + it "handles exceptions without controller info" do error_without_controller = double( "error", + id: 50, exception_class: "ArgumentError", message: "Invalid argument" ) exception_without_controller = double( "occurrence", + id: 8, created_at: Time.new(2025, 10, 21, 14, 30, 0), context: {}, respond_to?: true, @@ -170,6 +207,7 @@ ) allow(exception_without_controller).to receive(:respond_to?).with(:context).and_return(true) allow(exception_without_controller).to receive(:try).with(:error).and_return(error_without_controller) + allow(exception_without_controller).to receive(:try).with(:id).and_return(8) result = adapter.format_for_banner(exception_without_controller) @@ -180,12 +218,14 @@ long_message = "a" * 150 long_error = double( "error", + id: 51, exception_class: "Error", message: long_message ) exception_with_long_message = double( "occurrence", + id: 9, created_at: Time.new(2025, 10, 21, 14, 30, 0), context: {}, respond_to?: true, @@ -193,6 +233,7 @@ ) allow(exception_with_long_message).to receive(:respond_to?).with(:context).and_return(true) allow(exception_with_long_message).to receive(:try).with(:error).and_return(long_error) + allow(exception_with_long_message).to receive(:try).with(:id).and_return(9) result = adapter.format_for_banner(exception_with_long_message) @@ -202,12 +243,14 @@ it "handles message from context when exception message is empty" do error_with_empty_message = double( "error", + id: 52, exception_class: "StandardError", message: nil ) exception_with_context_message = double( "occurrence", + id: 10, created_at: Time.new(2025, 10, 21, 14, 30, 0), context: {"message" => "Error from context"}, respond_to?: true, @@ -215,6 +258,7 @@ ) allow(exception_with_context_message).to receive(:respond_to?).with(:context).and_return(true) allow(exception_with_context_message).to receive(:try).with(:error).and_return(error_with_empty_message) + allow(exception_with_context_message).to receive(:try).with(:id).and_return(10) result = adapter.format_for_banner(exception_with_context_message) diff --git a/spec/newshound/middleware/banner_injector_spec.rb b/spec/newshound/middleware/banner_injector_spec.rb new file mode 100644 index 0000000..af5ea27 --- /dev/null +++ b/spec/newshound/middleware/banner_injector_spec.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +RSpec.describe Newshound::Middleware::BannerInjector do + let(:app) { ->(env) { [200, {"Content-Type" => "text/html"}, ["Hello"]] } } + let(:middleware) { described_class.new(app) } + let(:configuration) { Newshound::Configuration.new } + let(:controller) { double("controller") } + + let(:env) do + {"action_controller.instance" => controller} + end + + before do + allow(Newshound).to receive(:configuration).and_return(configuration) + allow(Newshound::Authorization).to receive(:authorized?).with(controller).and_return(true) + + # Default: no data from reporters + allow_any_instance_of(Newshound::ExceptionReporter).to receive(:banner_data).and_return(exceptions: []) + allow_any_instance_of(Newshound::JobReporter).to receive(:banner_data).and_return(queue_stats: {}) + allow_any_instance_of(Newshound::WarningReporter).to receive(:banner_data).and_return(warnings: []) + end + + def response_body(env) + _status, _headers, body = middleware.call(env) + body.first + end + + describe "exception links" do + let(:exception_data) do + { + exceptions: [ + {id: 42, title: "NoMethodError", message: "undefined method", location: "UsersController#show", time: "02:30 PM"} + ] + } + end + + before do + allow_any_instance_of(Newshound::ExceptionReporter).to receive(:banner_data).and_return(exception_data) + end + + context "when exception_links are not configured" do + it "renders exception items without links" do + html = response_body(env) + + expect(html).to include("NoMethodError") + expect(html).not_to include("]*>.*Recent Exceptions}m) + end + end + + context "when exception_links index is configured" do + before do + configuration.exception_links = {index: "/errors"} + end + + it "renders the section title as a link" do + html = response_body(env) + + expect(html).to match(%r{]*href="/errors"[^>]*>.*Recent Exceptions}m) + end + end + + context "when exception_links show is configured" do + before do + configuration.exception_links = {show: "/errors/:id"} + end + + it "renders each exception item as a link with the ID interpolated" do + html = response_body(env) + + expect(html).to match(%r{]*href="/errors/42"}) + end + + it "does not render a link when the exception has no id" do + allow_any_instance_of(Newshound::ExceptionReporter).to receive(:banner_data).and_return( + exceptions: [ + {title: "NoMethodError", message: "undefined method", location: "UsersController#show", time: "02:30 PM"} + ] + ) + + html = response_body(env) + + expect(html).to include("NoMethodError") + expect(html).not_to include("]*href="/errors"[^>]*>.*Recent Exceptions}m) + expect(html).to match(%r{]*href="/errors/42"}) + end + end + end + + describe "job links" do + let(:job_data) do + { + queue_stats: { + ready_to_run: 3, + scheduled: 5, + failed: 2, + completed_today: 15 + } + } + end + + before do + allow_any_instance_of(Newshound::JobReporter).to receive(:banner_data).and_return(job_data) + end + + context "when job_links are not configured" do + it "renders job stats without links" do + html = response_body(env) + + expect(html).to include("Ready") + expect(html).to include("Scheduled") + expect(html).to include("Failed") + expect(html).to include("Completed Today") + expect(html).not_to match(%r{]*class="newshound-stat}) + end + end + + context "when job_links index is configured" do + before do + configuration.job_links = {index: "/background_jobs"} + end + + it "renders the section title as a link" do + html = response_body(env) + + expect(html).to match(%r{]*href="/background_jobs"[^>]*>.*Job Queue Status}m) + end + + it "links the Ready stat to the index" do + html = response_body(env) + + expect(html).to match(%r{]*href="/background_jobs"[^>]*>.*Ready}m) + end + end + + context "when job_links scheduled is configured" do + before do + configuration.job_links = {scheduled: "/background_jobs/scheduled"} + end + + it "links the Scheduled stat" do + html = response_body(env) + + expect(html).to match(%r{]*href="/background_jobs/scheduled"[^>]*>.*Scheduled}m) + end + end + + context "when job_links failed is configured" do + before do + configuration.job_links = {failed: "/background_jobs/failed"} + end + + it "links the Failed stat" do + html = response_body(env) + + expect(html).to match(%r{]*href="/background_jobs/failed"[^>]*>.*Failed}m) + end + end + + context "when job_links completed is configured" do + before do + configuration.job_links = {completed: "/background_jobs/completed"} + end + + it "links the Completed Today stat" do + html = response_body(env) + + expect(html).to match(%r{]*href="/background_jobs/completed"[^>]*>.*Completed Today}m) + end + end + + context "when all job_links are configured" do + before do + configuration.job_links = { + index: "/background_jobs", + scheduled: "/background_jobs/scheduled", + failed: "/background_jobs/failed", + completed: "/background_jobs/completed" + } + end + + it "links each stat to its respective path" do + html = response_body(env) + + expect(html).to match(%r{]*href="/background_jobs"[^>]*>.*Job Queue Status}m) + expect(html).to match(%r{]*href="/background_jobs/scheduled"[^>]*>.*Scheduled}m) + expect(html).to match(%r{]*href="/background_jobs/failed"[^>]*>.*Failed}m) + expect(html).to match(%r{]*href="/background_jobs/completed"[^>]*>.*Completed Today}m) + end + end + end + + describe "warning links" do + let(:warning_data) do + { + warnings: [ + {id: 7, title: "Deprecation Warning", message: "Method will be removed", location: "legacy.rb:42", time: "01:00 PM"} + ] + } + end + + before do + allow_any_instance_of(Newshound::WarningReporter).to receive(:banner_data).and_return(warning_data) + end + + context "when warning_links are not configured" do + it "renders warning items without links" do + html = response_body(env) + + expect(html).to include("Deprecation Warning") + expect(html).not_to match(%r{]*href=.*Deprecation Warning}m) + end + end + + context "when warning_links index is configured" do + before do + configuration.warning_links = {index: "/warnings"} + end + + it "renders the section title as a link" do + html = response_body(env) + + expect(html).to match(%r{]*href="/warnings"[^>]*>.*Warnings}m) + end + end + + context "when warning_links show is configured" do + before do + configuration.warning_links = {show: "/warnings/:id"} + end + + it "renders each warning item as a link with the ID interpolated" do + html = response_body(env) + + expect(html).to match(%r{]*href="/warnings/7"}) + end + end + end + + describe "link styling" do + before do + configuration.exception_links = {index: "/errors", show: "/errors/:id"} + allow_any_instance_of(Newshound::ExceptionReporter).to receive(:banner_data).and_return( + exceptions: [{id: 1, title: "Error", message: "msg", location: "loc", time: "12:00 PM"}] + ) + end + + it "styles links to inherit color and remove underline" do + html = response_body(env) + + expect(html).to include("color: inherit") + expect(html).to include("text-decoration: none") + end + end + + describe "HTML safety" do + before do + configuration.exception_links = {show: "/errors/:id"} + end + + it "escapes exception data in linked items" do + allow_any_instance_of(Newshound::ExceptionReporter).to receive(:banner_data).and_return( + exceptions: [{id: 1, title: "", message: "msg", location: "loc", time: "12:00 PM"}] + ) + + html = response_body(env) + + expect(html).not_to include("