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 %(
-
#{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("