From 1df1636cf45a8bb1e901f793621e088abbd59a05 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Wed, 11 Mar 2026 17:10:04 -0400 Subject: [PATCH 1/3] Add configurable links for banner items Allow users to configure clickable links in the banner for exceptions, jobs, and warnings via string path patterns with :id interpolation. Added: Configurable banner links for exceptions, jobs, and warnings via string path patterns Version: minor Co-Authored-By: Claude Opus 4.6 --- .../newshound/install/templates/newshound.rb | 22 ++ lib/newshound/configuration.rb | 6 +- lib/newshound/middleware/banner_injector.rb | 111 +++++-- spec/newshound/configuration_spec.rb | 61 ++++ .../middleware/banner_injector_spec.rb | 290 ++++++++++++++++++ 5 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 spec/newshound/configuration_spec.rb create mode 100644 spec/newshound/middleware/banner_injector_spec.rb 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/middleware/banner_injector.rb b/lib/newshound/middleware/banner_injector.rb index cfb1c5f..7a812a1 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 @@ -304,25 +318,38 @@ def summary_badge(exception_data, job_data, warning_data = {}) def render_exceptions(data) exceptions = data[:exceptions] || [] + links = Newshound.configuration.exception_links if exceptions.empty? 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])} -
+ item_content = <<~HTML +
#{escape_html(ex[:title])}
+
+ #{escape_html(ex[:message])} • #{escape_html(ex[:location])} • #{escape_html(ex[:time])}
HTML + + if links[:show] && ex[:id] + url = links[:show].gsub(":id", ex[:id].to_s) + %(
#{item_content}
) + else + %(
#{item_content}
) + end end.join + section_title = "⚠️ Recent Exceptions (#{exceptions.length})" + title_html = if links[:index] + %(#{section_title}) + else + section_title + end + <<~HTML
-
⚠️ Recent Exceptions (#{exceptions.length})
+
#{title_html}
#{items}
HTML @@ -330,23 +357,36 @@ def render_exceptions(data) def render_warnings(data) warnings = data[:warnings] || [] + links = Newshound.configuration.warning_links 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])} -
+ item_content = <<~HTML +
#{escape_html(w[:title])}
+
+ #{escape_html(w[:message])} • #{escape_html(w[:location])} • #{escape_html(w[:time])}
HTML + + if links[:show] && w[:id] + url = links[:show].gsub(":id", w[:id].to_s) + %(
#{item_content}
) + else + %(
#{item_content}
) + end end.join + section_title = "⚠️ Warnings (#{warnings.length})" + title_html = if links[:index] + %(#{section_title}) + else + section_title + end + <<~HTML
-
⚠️ Warnings (#{warnings.length})
+
#{title_html}
#{items}
HTML @@ -354,32 +394,41 @@ def render_warnings(data) 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/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/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("