Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lib/generators/newshound/install/templates/newshound.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion lib/newshound/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/newshound/exceptions/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/newshound/exceptions/exception_track.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions lib/newshound/exceptions/solid_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
116 changes: 73 additions & 43 deletions lib/newshound/middleware/banner_injector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
</style>
CSS
end
Expand Down Expand Up @@ -309,77 +323,93 @@ def render_exceptions(data)
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>)
end

items = exceptions.take(5).map do |ex|
<<~HTML
<div class="newshound-item">
<div class="newshound-item-title">#{escape_html(ex[:title])}</div>
<div class="newshound-item-detail">
#{escape_html(ex[:message])} • #{escape_html(ex[:location])} • #{escape_html(ex[:time])}
</div>
</div>
HTML
end.join

<<~HTML
<div class="newshound-section">
<div class="newshound-section-title">⚠️ Recent Exceptions (#{exceptions.length})</div>
#{items}
</div>
HTML
render_item_section(
items: exceptions,
links: Newshound.configuration.exception_links,
title: "⚠️ Recent Exceptions (#{exceptions.length})"
)
end

def render_warnings(data)
warnings = data[:warnings] || []

return "" if warnings.empty?

items = warnings.take(5).map do |w|
<<~HTML
<div class="newshound-item">
<div class="newshound-item-title">#{escape_html(w[:title])}</div>
<div class="newshound-item-detail">
#{escape_html(w[:message])} • #{escape_html(w[:location])} • #{escape_html(w[:time])}
</div>
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
<div class="newshound-item-title">#{escape_html(item[:title])}</div>
<div class="newshound-item-detail">
#{escape_html(item[:message])} • #{escape_html(item[:location])} • #{escape_html(item[:time])}
</div>
HTML

if links[:show] && item[:id]
url = links[:show].gsub(":id", item[:id].to_s)
%(<a href="#{escape_html(url)}" class="newshound-link"><div class="newshound-item">#{item_content}</div></a>)
else
%(<div class="newshound-item">#{item_content}</div>)
end
end.join

title_html = if links[:index]
%(<a href="#{escape_html(links[:index])}" class="newshound-link">#{title}</a>)
else
title
end

<<~HTML
<div class="newshound-section">
<div class="newshound-section-title">⚠️ Warnings (#{warnings.length})</div>
#{items}
<div class="newshound-section-title">#{title_html}</div>
#{rendered_items}
</div>
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]
%(<a href="#{escape_html(links[:index])}" class="newshound-link">#{section_title}</a>)
else
section_title
end

<<~HTML
<div class="newshound-section">
<div class="newshound-section-title">📊 Job Queue Status</div>
<div class="newshound-section-title">#{title_html}</div>
<div class="newshound-grid">
<div class="newshound-stat">
<span class="newshound-stat-value">#{stats[:ready_to_run] || 0}</span>
<span class="newshound-stat-label">Ready</span>
</div>
<div class="newshound-stat">
<span class="newshound-stat-value">#{stats[:scheduled] || 0}</span>
<span class="newshound-stat-label">Scheduled</span>
</div>
<div class="newshound-stat">
<span class="newshound-stat-value">#{stats[:failed] || 0}</span>
<span class="newshound-stat-label">Failed</span>
</div>
<div class="newshound-stat">
<span class="newshound-stat-value">#{stats[:completed_today] || 0}</span>
<span class="newshound-stat-label">Completed Today</span>
</div>
#{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])}
</div>
</div>
HTML
end

def render_job_stat(value, label, link)
content = <<~HTML
<span class="newshound-stat-value">#{value}</span>
<span class="newshound-stat-label">#{label}</span>
HTML

if link
%(<a href="#{escape_html(link)}" class="newshound-stat">#{content}</a>)
else
%(<div class="newshound-stat">#{content}</div>)
end
end

def escape_html(text)
return +"" unless text.present?
text.to_s
Expand Down
2 changes: 1 addition & 1 deletion lib/newshound/warnings/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions spec/newshound/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions spec/newshound/exceptions/exception_track_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"}',
Expand All @@ -99,29 +100,39 @@
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",
time: "02:30 PM"
})
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"}',
respond_to?: true
)
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)

Expand All @@ -132,13 +143,15 @@
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}\"}",
respond_to?: true
)
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)

Expand Down
Loading
Loading