Skip to content

Commit f7ab3c5

Browse files
authored
Feature/metrics/rich text links (#787)
This pull request introduces a comprehensive set of features for link checking and reporting within the BetterTogether metrics system. It adds new models, background jobs, controllers, and mailers to support automated link validation, reporting, and visualization. The changes are grouped into three main themes: link checking jobs, reporting and controller logic, and metrics visualization. **Link Checking Infrastructure:** * Added background jobs for checking external and internal links (`ExternalLinkCheckerJob`, `InternalLinkCheckerJob`), including queueing logic for distributing link checks across hosts and time windows to avoid overload (`RichTextLinkCheckerQueueJob` and its subclasses). [[1]](diffhunk://#diff-892afcf9e3cfd685faa3728292aa5ed13c702044e463e59ecd17d80f96e2294cR1-R36) [[2]](diffhunk://#diff-3730260488b5742d6a5347f090b2fe1b59912fdeee917619322f35b89141e641R1-R36) [[3]](diffhunk://#diff-1d331c511cdc570fc77a99bb25535df3a08978c61df959203e2851f4193e6e4cR1-R72) [[4]](diffhunk://#diff-5a0fdabb875d8c63dd7b51d2f6df28fd2997f78490bde8957e0282d2fc0e6355R1-R20) [[5]](diffhunk://#diff-ac53c5dee8b264f713f404351d664e61dbe89219a8ee72875d0501795fab0decR1-R24) * Introduced the `BetterTogether::Content::Link` model to persist discovered links and their metadata, and a namespace helper for links-related tables. [[1]](diffhunk://#diff-3fc39697bcc3a4d31a333d88732f189e98265dc7ffc2fa29ee337dff304e17d6R1-R22) [[2]](diffhunk://#diff-fb999437387b6d81186947f52d8c17a9399c270af2f106e469615284be454f06R1-R11) **Reporting and Controller Logic:** * Implemented `LinkCheckerReportsController` to support creating, listing, and downloading link checker reports, including Turbo Stream support for dynamic updates. * Added a scheduler job for generating and emailing link checker reports (`LinkCheckerReportSchedulerJob`) and a mailer for delivering these reports. [[1]](diffhunk://#diff-64c7d345d921d3b7820f2dd9e270c04cdf3c4bace7e649e4c9de41c4dc109a89R1-R20) [[2]](diffhunk://#diff-116ca85b0e59523938b0694f2fd80d23c50bfa970460b583222f3ca0e5519729R1-R25) * Added an event reminder scan job to periodically enqueue reminders for upcoming events. **Metrics Visualization and Testing:** * Enhanced metrics reporting by aggregating link data for charts (links by host, invalid links by host, failures over time) and exposed these in the metrics reports controller. * Updated the metrics charts controller and JavaScript to render new charts for link-related metrics. [[1]](diffhunk://#diff-92ebbff7abacb731625c22d4c9c4a62d0de515eab1fb84c6bb6f8cc6160dbae3L61-R61) [[2]](diffhunk://#diff-92ebbff7abacb731625c22d4c9c4a62d0de515eab1fb84c6bb6f8cc6160dbae3R71-R73) [[3]](diffhunk://#diff-92ebbff7abacb731625c22d4c9c4a62d0de515eab1fb84c6bb6f8cc6160dbae3R209-R262) * Added the `webmock` gem for stubbing external HTTP requests in specs, improving test reliability.
2 parents 3898c44 + 1e20be2 commit f7ab3c5

File tree

64 files changed

+2037
-20
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+2037
-20
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ group :test do
9898
# Capybara for integration testing
9999
gem 'capybara', '>= 2.15'
100100
gem 'capybara-screenshot'
101+
# WebMock for stubbing external HTTP requests in specs
102+
gem 'webmock'
101103
# Coveralls for test coverage reporting
102104
gem 'coveralls_reborn', require: false
103105
# Database cleaner for test database cleaning

Gemfile.lock

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ PATH
5757
reform-rails (>= 0.2, < 0.4)
5858
rswag (>= 2.3.1, < 2.17.0)
5959
ruby-openai
60+
sidekiq-scheduler
6061
simple_calendar
6162
sprockets-rails
6263
stackprof
@@ -229,6 +230,9 @@ GEM
229230
term-ansicolor (~> 1.7)
230231
thor (~> 1.2)
231232
tins (~> 1.32)
233+
crack (1.0.0)
234+
bigdecimal
235+
rexml
232236
crass (1.0.6)
233237
css_parser (1.21.1)
234238
addressable
@@ -297,6 +301,8 @@ GEM
297301
multi_json
298302
erb (5.0.2)
299303
erubi (1.13.1)
304+
et-orbi (1.3.0)
305+
tzinfo
300306
event_stream_parser (1.0.0)
301307
excon (1.2.8)
302308
logger
@@ -343,6 +349,9 @@ GEM
343349
friendly_id-mobility (1.0.4)
344350
friendly_id (>= 5.0.0, < 5.5)
345351
mobility (>= 1.0.1, < 2.0)
352+
fugit (1.11.2)
353+
et-orbi (~> 1, >= 1.2.11)
354+
raabro (~> 1.4)
346355
fuubar (2.5.1)
347356
rspec-core (~> 3.0)
348357
ruby-progressbar (~> 1.4)
@@ -362,6 +371,7 @@ GEM
362371
rake (>= 13)
363372
groupdate (6.7.0)
364373
activesupport (>= 7.1)
374+
hashdiff (1.2.0)
365375
hashie (5.0.0)
366376
highline (3.1.2)
367377
reline
@@ -521,6 +531,7 @@ GEM
521531
nio4r (~> 2.0)
522532
pundit (2.5.0)
523533
activesupport (>= 3.0.0)
534+
raabro (1.4.0)
524535
racc (1.8.1)
525536
rack (3.1.16)
526537
rack-attack (6.7.0)
@@ -699,6 +710,8 @@ GEM
699710
ffi (~> 1.12)
700711
logger
701712
rubyzip (3.0.1)
713+
rufus-scheduler (3.9.2)
714+
fugit (~> 1.1, >= 1.11.1)
702715
sass-embedded (1.86.3-aarch64-linux-gnu)
703716
google-protobuf (~> 4.30)
704717
sass-embedded (1.86.3-arm64-darwin)
@@ -732,6 +745,9 @@ GEM
732745
logger (>= 1.6.2)
733746
rack (>= 3.1.0)
734747
redis-client (>= 0.23.2)
748+
sidekiq-scheduler (6.0.1)
749+
rufus-scheduler (~> 3.2)
750+
sidekiq (>= 7.3, < 9)
735751
simple_calendar (3.1.0)
736752
rails (>= 6.1)
737753
simplecov (0.22.0)
@@ -801,6 +817,10 @@ GEM
801817
activemodel (>= 6.0.0)
802818
bindex (>= 0.4.0)
803819
railties (>= 6.0.0)
820+
webmock (3.25.1)
821+
addressable (>= 2.8.0)
822+
crack (>= 0.3.2)
823+
hashdiff (>= 0.4.0, < 2.0.0)
804824
websocket (1.2.11)
805825
websocket-driver (0.8.0)
806826
base64
@@ -873,6 +893,7 @@ DEPENDENCIES
873893
storext!
874894
uglifier (>= 1.3.0)
875895
web-console (>= 3.3.0)
896+
webmock
876897

877898
RUBY VERSION
878899
ruby 3.4.4p34
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
module Metrics
5+
# Controller for creating and downloading Link Checker reports
6+
class LinkCheckerReportsController < ApplicationController
7+
before_action :set_report, only: %i[download]
8+
9+
def index
10+
@link_checker_reports = BetterTogether::Metrics::LinkCheckerReport.order(created_at: :desc)
11+
12+
if request.headers['Turbo-Frame'].present?
13+
render partial: 'better_together/metrics/link_checker_reports/index',
14+
locals: { reports: @link_checker_reports },
15+
layout: false
16+
else
17+
render :index
18+
end
19+
end
20+
21+
def new
22+
@link_checker_report = BetterTogether::Metrics::LinkCheckerReport.new
23+
end
24+
25+
# rubocop:todo Metrics/AbcSize
26+
# rubocop:todo Metrics/MethodLength
27+
# rubocop:todo Metrics/BlockLength
28+
def create
29+
opts = {
30+
from_date: params.dig(:metrics_link_checker_report, :filters, :from_date),
31+
to_date: params.dig(:metrics_link_checker_report, :filters, :to_date),
32+
file_format: params.dig(:metrics_link_checker_report, :file_format) || 'csv'
33+
}
34+
35+
@link_checker_report = BetterTogether::Metrics::LinkCheckerReport.create_and_generate!(**opts)
36+
37+
respond_to do |format| # rubocop:todo Metrics/BlockLength
38+
if @link_checker_report.persisted?
39+
flash[:notice] = t('flash.generic.created', resource: t('resources.report'))
40+
format.html { redirect_to metrics_link_checker_reports_path, notice: flash[:notice] }
41+
format.turbo_stream do
42+
render turbo_stream: [
43+
turbo_stream.prepend(
44+
'link_checker_reports_table_body',
45+
partial: 'better_together/metrics/link_checker_reports/link_checker_report',
46+
locals: { link_checker_report: @link_checker_report }
47+
),
48+
turbo_stream.replace(
49+
'flash_messages',
50+
partial: 'layouts/better_together/flash_messages',
51+
locals: { flash: flash }
52+
),
53+
turbo_stream.replace('new_link_checker_report',
54+
'<turbo-frame id="new_link_checker_report"></turbo-frame>')
55+
]
56+
end
57+
else
58+
flash.now[:alert] = t('flash.generic.error_create', resource: t('resources.report'))
59+
format.html { render :new, status: :unprocessable_content }
60+
format.turbo_stream do
61+
render turbo_stream: [
62+
turbo_stream.update('form_errors', partial: 'layouts/errors', locals: { object: @link_checker_report }),
63+
turbo_stream.replace(
64+
'flash_messages',
65+
partial: 'layouts/better_together/flash_messages',
66+
locals: { flash: flash }
67+
)
68+
]
69+
end
70+
end
71+
end
72+
end
73+
# rubocop:enable Metrics/BlockLength
74+
# rubocop:enable Metrics/MethodLength
75+
# rubocop:enable Metrics/AbcSize
76+
77+
# rubocop:todo Metrics/AbcSize
78+
# rubocop:todo Metrics/MethodLength
79+
def download
80+
if @link_checker_report.report_file.attached?
81+
BetterTogether::Metrics::TrackDownloadJob.perform_later(
82+
@link_checker_report,
83+
@link_checker_report.report_file.filename.to_s,
84+
@link_checker_report.report_file.content_type,
85+
@link_checker_report.report_file.byte_size,
86+
I18n.locale.to_s
87+
)
88+
89+
send_data @link_checker_report.report_file.download,
90+
filename: @link_checker_report.report_file.filename.to_s,
91+
type: @link_checker_report.report_file.content_type,
92+
disposition: 'attachment'
93+
94+
return
95+
end
96+
97+
redirect_to metrics_link_checker_reports_path, alert: t('resources.download_failed')
98+
end
99+
# rubocop:enable Metrics/MethodLength
100+
# rubocop:enable Metrics/AbcSize
101+
102+
private
103+
104+
def set_report
105+
@link_checker_report = BetterTogether::Metrics::LinkCheckerReport.find(params[:id])
106+
end
107+
end
108+
end
109+
end

app/controllers/better_together/metrics/reports_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ def index # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
6767
}
6868
end
6969
}
70+
71+
# Link Checker charts: aggregate data from stored links
72+
links_scope = BetterTogether::Content::Link.all
73+
@links_by_host = links_scope.group(:host).count
74+
@invalid_by_host = links_scope.where(valid_link: false).group(:host).count
75+
@failures_daily = links_scope.where(valid_link: false).group_by_day(:last_checked_at).count
7076
end
7177

7278
# A helper method to generate a random color for each platform (this can be customized).

app/javascript/controllers/better_together/metrics_charts_controller.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const platformBorderColors = {
5858
};
5959

6060
export default class extends Controller {
61-
static targets = ["pageViewsChart", "dailyPageViewsChart", "linkClicksChart", "dailyLinkClicksChart", "downloadsChart", "sharesChart", "sharesPerUrlPerPlatformChart"]
61+
static targets = ["pageViewsChart", "dailyPageViewsChart", "linkClicksChart", "dailyLinkClicksChart", "downloadsChart", "sharesChart", "sharesPerUrlPerPlatformChart", "linksByHostChart", "invalidByHostChart", "failuresDailyChart"]
6262

6363
connect() {
6464
this.renderPageViewsChart()
@@ -68,6 +68,9 @@ export default class extends Controller {
6868
this.renderDownloadsChart()
6969
this.renderSharesChart()
7070
this.renderSharesPerUrlPerPlatformChart()
71+
this.renderLinksByHostChart()
72+
this.renderInvalidByHostChart()
73+
this.renderFailuresDailyChart()
7174
}
7275

7376
renderPageViewsChart() {
@@ -203,4 +206,58 @@ export default class extends Controller {
203206
})
204207
})
205208
}
209+
210+
renderLinksByHostChart() {
211+
const data = JSON.parse(this.linksByHostChartTarget.dataset.chartData)
212+
new Chart(this.linksByHostChartTarget, {
213+
type: 'bar',
214+
data: {
215+
labels: data.labels,
216+
datasets: [{
217+
label: 'Links by Host',
218+
data: data.values,
219+
backgroundColor: 'rgba(99, 132, 255, 0.2)',
220+
borderColor: 'rgba(99, 132, 255, 1)',
221+
borderWidth: 1
222+
}]
223+
},
224+
options: Object.assign({}, sharedChartOptions)
225+
})
226+
}
227+
228+
renderInvalidByHostChart() {
229+
const data = JSON.parse(this.invalidByHostChartTarget.dataset.chartData)
230+
new Chart(this.invalidByHostChartTarget, {
231+
type: 'bar',
232+
data: {
233+
labels: data.labels,
234+
datasets: [{
235+
label: 'Invalid Links by Host',
236+
data: data.values,
237+
backgroundColor: 'rgba(255, 159, 64, 0.2)',
238+
borderColor: 'rgba(255, 159, 64, 1)',
239+
borderWidth: 1
240+
}]
241+
},
242+
options: Object.assign({}, sharedChartOptions)
243+
})
244+
}
245+
246+
renderFailuresDailyChart() {
247+
const data = JSON.parse(this.failuresDailyChartTarget.dataset.chartData)
248+
new Chart(this.failuresDailyChartTarget, {
249+
type: 'line',
250+
data: {
251+
labels: data.labels,
252+
datasets: [{
253+
label: 'Invalid Links Over Time',
254+
data: data.values,
255+
backgroundColor: 'rgba(255, 99, 132, 0.2)',
256+
borderColor: 'rgba(255, 99, 132, 1)',
257+
borderWidth: 1
258+
}]
259+
},
260+
options: Object.assign({}, sharedChartOptions)
261+
})
262+
}
206263
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
# Scans upcoming events and schedules per-event reminders via
5+
# BetterTogether::EventReminderSchedulerJob. This job is intended to be run
6+
# periodically by the scheduler (sidekiq-scheduler / sidekiq-cron).
7+
class EventReminderScanJob < ApplicationJob
8+
queue_as :notifications
9+
10+
# Keep lightweight: find events starting in the near future and enqueue the
11+
# existing per-event scheduler job which handles cancellation/rescheduling.
12+
# default: next 7 days
13+
def perform(window_hours: 168)
14+
cutoff = Time.current + window_hours.hours
15+
16+
BetterTogether::Event.where('starts_at <= ? AND starts_at >= ?', cutoff, Time.current).find_each do |event|
17+
# Use the id to avoid serializing AR objects into the job payload
18+
BetterTogether::EventReminderSchedulerJob.perform_later(event.id)
19+
rescue StandardError => e
20+
Rails.logger.error "Failed to enqueue reminder scheduler for event #{event&.id}: #{e.message}"
21+
end
22+
end
23+
end
24+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
require 'net/http'
4+
require 'uri'
5+
6+
module BetterTogether
7+
module Metrics
8+
# Background job that checks an external link's HTTP status and updates
9+
# the corresponding BetterTogether::Content::Link record with the
10+
# latest check timestamp, status code and validity flag.
11+
class ExternalLinkCheckerJob < ApplicationJob
12+
queue_as :default
13+
14+
def perform(link_id)
15+
link = BetterTogether::Content::Link.find(link_id)
16+
checker = BetterTogether::Metrics::HttpLinkChecker.new(link.url)
17+
result = checker.call
18+
19+
update_link_from_result(link, result)
20+
end
21+
22+
private
23+
24+
def update_link_from_result(link, result)
25+
attrs = {
26+
last_checked_at: Time.current,
27+
latest_status_code: result.status_code,
28+
valid_link: result.success
29+
}
30+
31+
attrs[:error_message] = result.error&.message unless result.success
32+
link.update!(attrs)
33+
end
34+
end
35+
end
36+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
require 'net/http'
4+
require 'uri'
5+
6+
module BetterTogether
7+
module Metrics
8+
# Background job that checks an internal link's HTTP status and updates
9+
# the corresponding BetterTogether::Content::Link record with the
10+
# latest check timestamp, status code and validity flag.
11+
class InternalLinkCheckerJob < ApplicationJob
12+
queue_as :default
13+
14+
def perform(link_id)
15+
link = BetterTogether::Content::Link.find(link_id)
16+
checker = BetterTogether::Metrics::HttpLinkChecker.new(link.url)
17+
result = checker.call
18+
19+
update_link_from_result(link, result)
20+
end
21+
22+
private
23+
24+
def update_link_from_result(link, result)
25+
attrs = {
26+
last_checked_at: Time.current,
27+
latest_status_code: result.status_code,
28+
valid_link: result.success
29+
}
30+
31+
attrs[:error_message] = result.error&.message unless result.success
32+
link.update!(attrs)
33+
end
34+
end
35+
end
36+
end

0 commit comments

Comments
 (0)