source_monitor 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-configure/SKILL.md +10 -1
  3. data/.claude/skills/sm-configure/reference/configuration-reference.md +44 -0
  4. data/.claude/skills/sm-host-setup/reference/initializer-template.md +17 -0
  5. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +2 -0
  6. data/.claude/skills/sm-job/reference/job-conventions.md +26 -0
  7. data/.claude/skills/sm-upgrade/reference/version-history.md +22 -0
  8. data/.gitignore +10 -0
  9. data/AGENTS.md +1 -1
  10. data/CHANGELOG.md +35 -0
  11. data/CLAUDE.md +11 -5
  12. data/Gemfile.lock +1 -1
  13. data/README.md +6 -4
  14. data/VERSION +1 -1
  15. data/app/assets/builds/source_monitor/application.css +43 -0
  16. data/app/assets/builds/source_monitor/application.js +127 -0
  17. data/app/assets/builds/source_monitor/application.js.map +3 -3
  18. data/app/assets/javascripts/source_monitor/application.js +2 -0
  19. data/app/assets/javascripts/source_monitor/controllers/notification_container_controller.js +138 -0
  20. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +11 -0
  21. data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +38 -0
  22. data/app/controllers/source_monitor/sources_controller.rb +11 -0
  23. data/app/helpers/source_monitor/application_helper.rb +51 -0
  24. data/app/jobs/source_monitor/favicon_fetch_job.rb +71 -0
  25. data/app/jobs/source_monitor/import_opml_job.rb +9 -0
  26. data/app/jobs/source_monitor/source_health_check_job.rb +10 -0
  27. data/app/models/source_monitor/source.rb +2 -0
  28. data/app/views/layouts/source_monitor/application.html.erb +23 -2
  29. data/app/views/source_monitor/shared/_toast.html.erb +1 -0
  30. data/app/views/source_monitor/sources/_details.html.erb +34 -5
  31. data/app/views/source_monitor/sources/_row.html.erb +11 -6
  32. data/config/routes.rb +1 -0
  33. data/docs/configuration.md +1 -1
  34. data/docs/upgrade.md +22 -0
  35. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +15 -1
  36. data/lib/source_monitor/configuration/favicons_settings.rb +42 -0
  37. data/lib/source_monitor/configuration/http_settings.rb +1 -1
  38. data/lib/source_monitor/configuration/scraping_settings.rb +1 -1
  39. data/lib/source_monitor/configuration.rb +3 -1
  40. data/lib/source_monitor/favicons/discoverer.rb +196 -0
  41. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +21 -0
  42. data/lib/source_monitor/fetching/feed_fetcher.rb +1 -0
  43. data/lib/source_monitor/http.rb +5 -3
  44. data/lib/source_monitor/version.rb +1 -1
  45. data/lib/source_monitor.rb +4 -0
  46. data/source_monitor.gemspec +1 -1
  47. metadata +6 -106
  48. data/.vbw-planning/PROJECT.md +0 -51
  49. data/.vbw-planning/ROADMAP.md +0 -53
  50. data/.vbw-planning/SHIPPED.md +0 -63
  51. data/.vbw-planning/STATE.md +0 -27
  52. data/.vbw-planning/codebase/ARCHITECTURE.md +0 -147
  53. data/.vbw-planning/codebase/CONCERNS.md +0 -99
  54. data/.vbw-planning/codebase/CONVENTIONS.md +0 -97
  55. data/.vbw-planning/codebase/DEPENDENCIES.md +0 -100
  56. data/.vbw-planning/codebase/INDEX.md +0 -86
  57. data/.vbw-planning/codebase/META.md +0 -42
  58. data/.vbw-planning/codebase/PATTERNS.md +0 -262
  59. data/.vbw-planning/codebase/STACK.md +0 -101
  60. data/.vbw-planning/codebase/STRUCTURE.md +0 -324
  61. data/.vbw-planning/codebase/TESTING.md +0 -154
  62. data/.vbw-planning/config.json +0 -53
  63. data/.vbw-planning/discovery.json +0 -26
  64. data/.vbw-planning/milestones/default/ROADMAP.md +0 -115
  65. data/.vbw-planning/milestones/default/STATE.md +0 -82
  66. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +0 -56
  67. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +0 -187
  68. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +0 -64
  69. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +0 -137
  70. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +0 -67
  71. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +0 -142
  72. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +0 -64
  73. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +0 -138
  74. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +0 -85
  75. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +0 -147
  76. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +0 -63
  77. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +0 -129
  78. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +0 -74
  79. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +0 -154
  80. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +0 -303
  81. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +0 -510
  82. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +0 -61
  83. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +0 -161
  84. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +0 -66
  85. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +0 -132
  86. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +0 -59
  87. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +0 -171
  88. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +0 -56
  89. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +0 -152
  90. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +0 -33
  91. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +0 -42
  92. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +0 -119
  93. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +0 -52
  94. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +0 -195
  95. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +0 -79
  96. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +0 -130
  97. data/.vbw-planning/milestones/generator-enhancements/REQUIREMENTS.md +0 -72
  98. data/.vbw-planning/milestones/generator-enhancements/ROADMAP.md +0 -125
  99. data/.vbw-planning/milestones/generator-enhancements/SHIPPED.md +0 -40
  100. data/.vbw-planning/milestones/generator-enhancements/STATE.md +0 -43
  101. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-CONTEXT.md +0 -33
  102. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-VERIFICATION.md +0 -86
  103. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01-SUMMARY.md +0 -61
  104. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01.md +0 -380
  105. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/02-VERIFICATION.md +0 -78
  106. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01-SUMMARY.md +0 -46
  107. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01.md +0 -500
  108. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/03-VERIFICATION.md +0 -89
  109. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01-SUMMARY.md +0 -48
  110. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01.md +0 -456
  111. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/04-VERIFICATION.md +0 -129
  112. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01-SUMMARY.md +0 -70
  113. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01.md +0 -747
  114. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/05-VERIFICATION.md +0 -156
  115. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01-SUMMARY.md +0 -69
  116. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01.md +0 -455
  117. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02-SUMMARY.md +0 -39
  118. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02.md +0 -488
  119. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/06-VERIFICATION.md +0 -100
  120. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md +0 -37
  121. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01.md +0 -345
  122. data/.vbw-planning/milestones/upgrade-assurance/REQUIREMENTS.md +0 -80
  123. data/.vbw-planning/milestones/upgrade-assurance/ROADMAP.md +0 -75
  124. data/.vbw-planning/milestones/upgrade-assurance/STATE.md +0 -29
  125. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/01-VERIFICATION.md +0 -144
  126. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01-SUMMARY.md +0 -43
  127. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01.md +0 -405
  128. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01-SUMMARY.md +0 -27
  129. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01.md +0 -303
  130. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/03-VERIFICATION.md +0 -380
  131. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01-SUMMARY.md +0 -36
  132. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01.md +0 -652
  133. data/.vbw-planning/phases/01-aia-certificate-resolution/.context-dev.md +0 -17
  134. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01-SUMMARY.md +0 -26
  135. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01.md +0 -71
  136. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02-SUMMARY.md +0 -16
  137. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02.md +0 -56
  138. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03-SUMMARY.md +0 -17
  139. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03.md +0 -98
  140. data/.vbw-planning/phases/02-test-performance/.context-dev.md +0 -75
  141. data/.vbw-planning/phases/02-test-performance/.context-lead.md +0 -89
  142. data/.vbw-planning/phases/02-test-performance/.context-qa.md +0 -23
  143. data/.vbw-planning/phases/02-test-performance/02-RESEARCH.md +0 -56
  144. data/.vbw-planning/phases/02-test-performance/02-VERIFICATION.md +0 -51
  145. data/.vbw-planning/phases/02-test-performance/PLAN-01-SUMMARY.md +0 -37
  146. data/.vbw-planning/phases/02-test-performance/PLAN-01.md +0 -156
  147. data/.vbw-planning/phases/02-test-performance/PLAN-02-SUMMARY.md +0 -33
  148. data/.vbw-planning/phases/02-test-performance/PLAN-02.md +0 -120
  149. data/.vbw-planning/phases/02-test-performance/PLAN-03-SUMMARY.md +0 -30
  150. data/.vbw-planning/phases/02-test-performance/PLAN-03.md +0 -154
  151. data/.vbw-planning/phases/02-test-performance/PLAN-04-SUMMARY.md +0 -28
  152. data/.vbw-planning/phases/02-test-performance/PLAN-04.md +0 -133
@@ -1,6 +1,7 @@
1
1
  import { Application } from "@hotwired/stimulus";
2
2
  import AsyncSubmitController from "./controllers/async_submit_controller";
3
3
  import NotificationController from "./controllers/notification_controller";
4
+ import NotificationContainerController from "./controllers/notification_container_controller";
4
5
  import DropdownController from "./controllers/dropdown_controller";
5
6
  import ModalController from "./controllers/modal_controller";
6
7
  import ConfirmNavigationController from "./controllers/confirm_navigation_controller";
@@ -15,6 +16,7 @@ if (!existingApplication) {
15
16
  }
16
17
 
17
18
  application.register("notification", NotificationController);
19
+ application.register("notification-container", NotificationContainerController);
18
20
  application.register("async-submit", AsyncSubmitController);
19
21
  application.register("dropdown", DropdownController);
20
22
  application.register("modal", ModalController);
@@ -0,0 +1,138 @@
1
+ /* global MutationObserver, requestAnimationFrame, cancelAnimationFrame */
2
+ import { Controller } from "@hotwired/stimulus";
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ maxVisible: { default: 3, type: Number },
7
+ expanded: { default: false, type: Boolean }
8
+ };
9
+
10
+ static targets = ["list", "badge", "badgeCount", "clearAll"];
11
+
12
+ connect() {
13
+ this.rafId = null;
14
+ this.boundHandleClickOutside = this.handleClickOutside.bind(this);
15
+ this.boundScheduleRecalculate = () => this.scheduleRecalculate();
16
+
17
+ this.observer = new MutationObserver(this.boundScheduleRecalculate);
18
+ this.observer.observe(this.listTarget, { childList: true });
19
+
20
+ this.listTarget.addEventListener(
21
+ "notification:dismissed",
22
+ this.boundScheduleRecalculate
23
+ );
24
+
25
+ this.recalculateVisibility();
26
+ }
27
+
28
+ disconnect() {
29
+ if (this.observer) {
30
+ this.observer.disconnect();
31
+ this.observer = null;
32
+ }
33
+
34
+ if (this.rafId) {
35
+ cancelAnimationFrame(this.rafId);
36
+ this.rafId = null;
37
+ }
38
+
39
+ this.listTarget.removeEventListener(
40
+ "notification:dismissed",
41
+ this.boundScheduleRecalculate
42
+ );
43
+ document.removeEventListener("click", this.boundHandleClickOutside);
44
+ }
45
+
46
+ scheduleRecalculate() {
47
+ if (this.rafId) {
48
+ cancelAnimationFrame(this.rafId);
49
+ }
50
+ this.rafId = requestAnimationFrame(() => {
51
+ this.rafId = null;
52
+ this.recalculateVisibility();
53
+ });
54
+ }
55
+
56
+ recalculateVisibility() {
57
+ const toasts = Array.from(this.listTarget.children);
58
+ const total = toasts.length;
59
+
60
+ if (this.expandedValue) {
61
+ toasts.forEach((toast) => {
62
+ toast.classList.remove("hidden");
63
+ toast.removeAttribute("aria-hidden");
64
+ toast.removeAttribute("inert");
65
+ });
66
+ } else {
67
+ toasts.forEach((toast, index) => {
68
+ if (index < this.maxVisibleValue) {
69
+ toast.classList.remove("hidden");
70
+ toast.removeAttribute("aria-hidden");
71
+ toast.removeAttribute("inert");
72
+ } else {
73
+ toast.classList.add("hidden");
74
+ toast.setAttribute("aria-hidden", "true");
75
+ toast.setAttribute("inert", "");
76
+ }
77
+ });
78
+ }
79
+
80
+ const hiddenCount = this.expandedValue
81
+ ? 0
82
+ : Math.max(0, total - this.maxVisibleValue);
83
+
84
+ if (this.hasBadgeTarget) {
85
+ if (this.hasBadgeCountTarget) {
86
+ this.badgeCountTarget.textContent = `+${hiddenCount} more`;
87
+ }
88
+ if (hiddenCount > 0) {
89
+ this.badgeTarget.classList.remove("hidden");
90
+ } else {
91
+ this.badgeTarget.classList.add("hidden");
92
+ }
93
+ }
94
+
95
+ if (this.hasClearAllTarget) {
96
+ const showClearAll = total > 0 && (hiddenCount > 0 || this.expandedValue);
97
+ if (showClearAll) {
98
+ this.clearAllTarget.classList.remove("hidden");
99
+ } else {
100
+ this.clearAllTarget.classList.add("hidden");
101
+ }
102
+ }
103
+ }
104
+
105
+ toggleExpand(event) {
106
+ event.preventDefault();
107
+ if (this.expandedValue) {
108
+ this.collapseStack();
109
+ } else {
110
+ this.expandStack();
111
+ }
112
+ }
113
+
114
+ expandStack() {
115
+ this.expandedValue = true;
116
+ this.recalculateVisibility();
117
+ document.addEventListener("click", this.boundHandleClickOutside);
118
+ }
119
+
120
+ collapseStack() {
121
+ this.expandedValue = false;
122
+ document.removeEventListener("click", this.boundHandleClickOutside);
123
+ this.recalculateVisibility();
124
+ }
125
+
126
+ handleClickOutside(event) {
127
+ if (!this.element.contains(event.target)) {
128
+ this.collapseStack();
129
+ }
130
+ }
131
+
132
+ clearAll(event) {
133
+ event.preventDefault();
134
+ const toasts = Array.from(this.listTarget.children);
135
+ toasts.forEach((toast) => toast.remove());
136
+ this.collapseStack();
137
+ }
138
+ }
@@ -12,6 +12,7 @@ export default class extends Controller {
12
12
 
13
13
  this.clearTimeout();
14
14
  this.registerController();
15
+ this.applyLevelDelay();
15
16
  this.startTimer();
16
17
  }
17
18
 
@@ -34,8 +35,18 @@ export default class extends Controller {
34
35
  this.timeoutId = window.setTimeout(() => this.dismiss(), this.delayValue);
35
36
  }
36
37
 
38
+ applyLevelDelay() {
39
+ const level = this.element.dataset.level;
40
+ if (level === "error" && this.delayValue === 5000) {
41
+ this.delayValue = 10000;
42
+ }
43
+ }
44
+
37
45
  dismiss() {
38
46
  if (!this.element) return;
47
+ this.element.dispatchEvent(
48
+ new CustomEvent("notification:dismissed", { bubbles: true })
49
+ );
39
50
  this.element.classList.add("opacity-0", "translate-y-2");
40
51
  window.setTimeout(() => {
41
52
  if (this.element && this.element.remove) {
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceFaviconFetchesController < ApplicationController
5
+ include SourceMonitor::SourceTurboResponses
6
+
7
+ before_action :set_source
8
+
9
+ def create
10
+ unless defined?(ActiveStorage) && SourceMonitor.config.favicons.enabled?
11
+ return render_fetch_enqueue_response("Favicon fetching is not enabled.", toast_level: :warning)
12
+ end
13
+
14
+ if @source.respond_to?(:favicon) && @source.favicon.attached?
15
+ @source.favicon.purge
16
+ end
17
+
18
+ # Clear cooldown so the job doesn't skip this attempt
19
+ clear_favicon_cooldown(@source)
20
+
21
+ SourceMonitor::FaviconFetchJob.perform_later(@source.id)
22
+ render_fetch_enqueue_response("Favicon fetch has been enqueued.")
23
+ rescue StandardError => error
24
+ handle_fetch_failure(error, prefix: "Favicon fetch")
25
+ end
26
+
27
+ private
28
+
29
+ def set_source
30
+ @source = Source.find(params[:source_id])
31
+ end
32
+
33
+ def clear_favicon_cooldown(source)
34
+ metadata = (source.metadata || {}).except("favicon_last_attempted_at")
35
+ source.update_column(:metadata, metadata)
36
+ end
37
+ end
38
+ end
@@ -55,6 +55,7 @@ module SourceMonitor
55
55
  @source = Source.new(source_params)
56
56
 
57
57
  if @source.save
58
+ enqueue_favicon_fetch(@source)
58
59
  redirect_to source_monitor.source_path(@source), notice: "Source created successfully"
59
60
  else
60
61
  render :new, status: :unprocessable_entity
@@ -130,5 +131,15 @@ module SourceMonitor
130
131
  sanitized = SourceMonitor::Security::ParameterSanitizer.sanitize(raw_value.to_s)
131
132
  sanitized.start_with?("/") ? sanitized : nil
132
133
  end
134
+
135
+ def enqueue_favicon_fetch(source)
136
+ return unless defined?(ActiveStorage)
137
+ return unless SourceMonitor.config.favicons.enabled?
138
+ return if source.website_url.blank?
139
+
140
+ SourceMonitor::FaviconFetchJob.perform_later(source.id)
141
+ rescue StandardError => error
142
+ Rails.logger.warn("[SourceMonitor] Failed to enqueue favicon fetch: #{error.message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
143
+ end
133
144
  end
134
145
  end
@@ -232,6 +232,23 @@ module SourceMonitor
232
232
  nil
233
233
  end
234
234
 
235
+ # Renders the source favicon as an <img> tag or a colored-circle initials
236
+ # placeholder when no favicon is attached. Handles the case where
237
+ # ActiveStorage is not loaded (host app without AS).
238
+ #
239
+ # Options:
240
+ # size: pixel dimension for width/height (default: 24)
241
+ # class: additional CSS classes
242
+ def source_favicon_tag(source, size: 24, **options)
243
+ css = options.delete(:class) || ""
244
+
245
+ if favicon_attached?(source)
246
+ favicon_image_tag(source, size: size, css: css)
247
+ else
248
+ favicon_placeholder_tag(source, size: size, css: css)
249
+ end
250
+ end
251
+
235
252
  private
236
253
 
237
254
  def external_link_icon
@@ -270,5 +287,39 @@ module SourceMonitor
270
287
 
271
288
  Rails.logger.debug("[SourceMonitor] Skipping #{kind} bundle include: #{error.message}")
272
289
  end
290
+
291
+ def favicon_attached?(source)
292
+ defined?(ActiveStorage) &&
293
+ source.respond_to?(:favicon) &&
294
+ source.favicon.attached?
295
+ end
296
+
297
+ def favicon_image_tag(source, size:, css:)
298
+ blob = source.favicon.blob
299
+ url = Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
300
+
301
+ image_tag(url,
302
+ alt: "#{source.name} favicon",
303
+ width: size,
304
+ height: size,
305
+ class: "rounded object-contain #{css}".strip,
306
+ style: "max-width: #{size}px; max-height: #{size}px;",
307
+ loading: "lazy")
308
+ rescue StandardError
309
+ favicon_placeholder_tag(source, size: size, css: css)
310
+ end
311
+
312
+ def favicon_placeholder_tag(source, size:, css:)
313
+ initial = source.name.to_s.strip.first.presence&.upcase || "?"
314
+ hue = source.name.to_s.bytes.sum % 360
315
+ bg_color = "hsl(#{hue}, 45%, 65%)"
316
+
317
+ content_tag(:span,
318
+ initial,
319
+ class: "inline-flex items-center justify-center rounded-full text-white font-semibold #{css}".strip,
320
+ style: "width: #{size}px; height: #{size}px; background-color: #{bg_color}; font-size: #{(size * 0.5).round}px; line-height: #{size}px;",
321
+ title: source.name,
322
+ "aria-hidden": "true")
323
+ end
273
324
  end
274
325
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class FaviconFetchJob < ApplicationJob
5
+ source_monitor_queue :fetch
6
+
7
+ discard_on ActiveJob::DeserializationError
8
+
9
+ def perform(source_id)
10
+ return unless defined?(ActiveStorage)
11
+
12
+ source = SourceMonitor::Source.find_by(id: source_id)
13
+ return unless source
14
+ return unless SourceMonitor.config.favicons.enabled?
15
+ return if source.website_url.blank?
16
+ return if source.favicon.attached?
17
+ return if within_cooldown?(source)
18
+
19
+ result = SourceMonitor::Favicons::Discoverer.new(source.website_url).call
20
+
21
+ if result
22
+ attach_favicon(source, result)
23
+ else
24
+ record_failed_attempt(source)
25
+ end
26
+ rescue StandardError => error
27
+ record_failed_attempt(source) if source
28
+ log_error(source, error)
29
+ end
30
+
31
+ private
32
+
33
+ def within_cooldown?(source)
34
+ last_attempt = source.metadata&.dig("favicon_last_attempted_at")
35
+ return false if last_attempt.blank?
36
+
37
+ cooldown_days = SourceMonitor.config.favicons.retry_cooldown_days
38
+ Time.parse(last_attempt) > cooldown_days.days.ago
39
+ rescue ArgumentError, TypeError
40
+ false
41
+ end
42
+
43
+ def attach_favicon(source, result)
44
+ blob = ActiveStorage::Blob.create_and_upload!(
45
+ io: result.io,
46
+ filename: result.filename,
47
+ content_type: result.content_type
48
+ )
49
+ source.favicon.attach(blob)
50
+ end
51
+
52
+ def record_failed_attempt(source)
53
+ metadata = (source.metadata || {}).merge(
54
+ "favicon_last_attempted_at" => Time.current.iso8601
55
+ )
56
+ source.update_column(:metadata, metadata)
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def log_error(source, error)
62
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
63
+
64
+ Rails.logger.warn(
65
+ "[SourceMonitor::FaviconFetchJob] Failed for source #{source&.id}: #{error.class} - #{error.message}"
66
+ )
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+ end
@@ -72,6 +72,7 @@ module SourceMonitor
72
72
 
73
73
  if source.save
74
74
  imported_sources << { id: source.id, feed_url: source.feed_url, name: source.name }
75
+ SourceMonitor::FaviconFetchJob.perform_later(source.id) if should_fetch_favicon?(source)
75
76
  processed << normalized_url
76
77
  else
77
78
  failed_sources << failure_payload(feed_url, "ValidationFailed", source.errors.full_messages.to_sentence)
@@ -130,6 +131,14 @@ module SourceMonitor
130
131
  }
131
132
  end
132
133
 
134
+ def should_fetch_favicon?(source)
135
+ defined?(ActiveStorage) &&
136
+ SourceMonitor.config.favicons.enabled? &&
137
+ source.website_url.present?
138
+ rescue StandardError
139
+ false
140
+ end
141
+
133
142
  def broadcast_completion(history)
134
143
  return unless defined?(Turbo::StreamsChannel)
135
144
 
@@ -12,6 +12,7 @@ module SourceMonitor
12
12
 
13
13
  result = SourceMonitor::Health::SourceHealthCheck.new(source: source).call
14
14
  broadcast_outcome(source, result)
15
+ trigger_fetch_if_degraded(source, result)
15
16
  result
16
17
  rescue StandardError => error
17
18
  Rails.logger&.error(
@@ -23,8 +24,17 @@ module SourceMonitor
23
24
  nil
24
25
  end
25
26
 
27
+ DEGRADED_STATUSES = %w[declining critical warning].freeze
28
+
26
29
  private
27
30
 
31
+ def trigger_fetch_if_degraded(source, result)
32
+ return unless result&.success?
33
+ return unless DEGRADED_STATUSES.include?(source.health_status.to_s)
34
+
35
+ SourceMonitor::FetchFeedJob.perform_later(source.id, force: true)
36
+ end
37
+
28
38
  def record_unexpected_failure(source, error)
29
39
  SourceMonitor::HealthCheckLog.create!(
30
40
  source: source,
@@ -8,6 +8,8 @@ module SourceMonitor
8
8
  include SourceMonitor::Models::Sanitizable
9
9
  include SourceMonitor::Models::UrlNormalizable
10
10
 
11
+ has_one_attached :favicon if defined?(ActiveStorage)
12
+
11
13
  FETCH_STATUS_VALUES = %w[idle queued fetching failed].freeze
12
14
 
13
15
  has_many :all_items, class_name: "SourceMonitor::Item", inverse_of: :source, dependent: :destroy
@@ -13,8 +13,29 @@
13
13
  </head>
14
14
  <body class="fm-admin">
15
15
  <%= turbo_stream_from "source_monitor_notifications" %>
16
- <div class="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-end px-6">
17
- <div id="source_monitor_notifications" class="flex w-full max-w-sm flex-col gap-3"></div>
16
+ <div class="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-end px-6"
17
+ data-controller="notification-container">
18
+ <div class="flex w-full max-w-sm flex-col items-end gap-3">
19
+ <div id="source_monitor_notifications"
20
+ data-notification-container-target="list"
21
+ class="flex w-full flex-col gap-3">
22
+ </div>
23
+ <div data-notification-container-target="badge"
24
+ class="pointer-events-auto hidden">
25
+ <button type="button"
26
+ data-action="notification-container#toggleExpand"
27
+ class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1 text-xs font-medium text-white shadow-lg transition hover:bg-slate-600"
28
+ aria-live="polite">
29
+ <span data-notification-container-target="badgeCount">+0 more</span>
30
+ </button>
31
+ </div>
32
+ <button type="button"
33
+ data-action="notification-container#clearAll"
34
+ data-notification-container-target="clearAll"
35
+ class="pointer-events-auto hidden text-xs font-medium text-slate-400 underline transition hover:text-white">
36
+ Clear all
37
+ </button>
38
+ </div>
18
39
  </div>
19
40
  <div id="source_monitor_redirects" class="hidden" aria-hidden="true"></div>
20
41
  <div class="flex min-h-screen flex-col">
@@ -13,6 +13,7 @@
13
13
  <div
14
14
  data-controller="notification"
15
15
  data-notification-delay-value="<%= delay_ms %>"
16
+ data-level="<%= level_key %>"
16
17
  class="pointer-events-auto w-full max-w-md rounded-lg border px-4 py-3 shadow-lg transition duration-300 <%= classes %>"
17
18
  >
18
19
  <div class="flex items-start justify-between gap-3">
@@ -8,13 +8,40 @@
8
8
 
9
9
  <div class="space-y-8">
10
10
  <div class="flex items-start justify-between">
11
- <div>
12
- <h1 class="text-3xl font-semibold text-slate-900"><%= source.name %></h1>
13
- <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
14
- <span data-testid="fetch-status-badge" class="inline-flex items-center rounded-full px-3 py-1 font-semibold <%= fetch_status[:classes] %>">
11
+ <div class="flex items-start gap-4">
12
+ <div class="flex flex-col items-center">
13
+ <div class="group relative">
14
+ <%= source_favicon_tag(source, size: 40) %>
15
+ <% if defined?(ActiveStorage) && SourceMonitor.config.favicons.enabled? %>
16
+ <%= button_to source_monitor.source_favicon_fetch_path(source),
17
+ method: :post,
18
+ class: "absolute -bottom-1 -right-1 hidden group-hover:inline-flex items-center justify-center rounded-full bg-white border border-slate-200 shadow-sm p-0.5 hover:bg-slate-50",
19
+ title: "Fetch favicon",
20
+ data: {
21
+ "async-submit-target": "button",
22
+ turbo_confirm: source.respond_to?(:favicon) && source.favicon.attached? ? "Replace existing favicon?" : nil
23
+ }.compact,
24
+ form: {
25
+ class: "inline",
26
+ data: {
27
+ controller: "async-submit",
28
+ action: "turbo:submit-start->async-submit#start turbo:submit-end->async-submit#finish"
29
+ }
30
+ } do %>
31
+ <svg class="h-4 w-4 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
32
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
33
+ </svg>
34
+ <% end %>
35
+ <% end %>
36
+ </div>
37
+ <span data-testid="fetch-status-badge" class="mt-2 inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%= fetch_status[:classes] %>">
15
38
  <%= loading_spinner_svg if fetch_status[:show_spinner] %>
16
39
  <%= fetch_status[:label] %>
17
40
  </span>
41
+ </div>
42
+ <div>
43
+ <h1 class="text-3xl font-semibold text-slate-900"><%= source.name %></h1>
44
+ <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
18
45
  <% if source.last_fetch_started_at.present? %>
19
46
  <span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-slate-600">
20
47
  Started <%= time_ago_in_words(source.last_fetch_started_at) %> ago
@@ -24,8 +51,10 @@
24
51
  Last fetched <%= time_ago_in_words(source.last_fetched_at) %> ago
25
52
  </span>
26
53
  <% end %>
54
+ <span class="text-slate-400">&middot;</span>
55
+ <%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %>
56
+ </div>
27
57
  </div>
28
- <p class="mt-2 text-sm text-slate-500">Feed URL: <%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></p>
29
58
  </div>
30
59
  <div class="flex flex-wrap items-center justify-end gap-3">
31
60
  <% fetch_disabled = %w[queued fetching].include?(source.fetch_status) %>
@@ -23,13 +23,18 @@
23
23
 
24
24
  <tr id="<%= dom_id(source, :row) %>" class="hover:bg-slate-50">
25
25
  <td class="px-6 py-4">
26
- <div class="font-medium text-slate-900">
27
- <%= link_to source.name,
28
- source_monitor.source_path(source),
29
- class: "text-slate-900 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
30
- data: { turbo_frame: "_top" } %>
26
+ <div class="flex items-center gap-3">
27
+ <%= source_favicon_tag(source, size: 24) %>
28
+ <div>
29
+ <div class="font-medium text-slate-900">
30
+ <%= link_to source.name,
31
+ source_monitor.source_path(source),
32
+ class: "text-slate-900 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
33
+ data: { turbo_frame: "_top" } %>
34
+ </div>
35
+ <div class="text-xs text-slate-500 truncate max-w-xs"><%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></div>
36
+ </div>
31
37
  </div>
32
- <div class="text-xs text-slate-500 truncate max-w-xs"><%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></div>
33
38
  </td>
34
39
  <td class="px-6 py-4">
35
40
  <div class="flex flex-col gap-2 text-xs">
data/config/routes.rb CHANGED
@@ -22,5 +22,6 @@ SourceMonitor::Engine.routes.draw do
22
22
  resource :bulk_scrape, only: :create, controller: "source_bulk_scrapes"
23
23
  resource :health_check, only: :create, controller: "source_health_checks"
24
24
  resource :health_reset, only: :create, controller: "source_health_resets"
25
+ resource :favicon_fetch, only: :create, controller: "source_favicon_fetches"
25
26
  end
26
27
  end
@@ -46,7 +46,7 @@ The helper `SourceMonitor.mission_control_dashboard_path` performs a routing che
46
46
  - `timeout` – total request timeout in seconds (default `15`)
47
47
  - `open_timeout` – connection open timeout in seconds (`5`)
48
48
  - `max_redirects` – maximum redirects to follow (`5`)
49
- - `user_agent` – defaults to `SourceMonitor/<version>`
49
+ - `user_agent` – defaults to `Mozilla/5.0 (compatible; SourceMonitor/<version>)` (browser-like to avoid bot-blocking)
50
50
  - `proxy` – hash or URL to configure proxy usage
51
51
  - `headers` – hash (or callables) merged into every request
52
52
  - `retry_max`, `retry_interval`, `retry_interval_randomness`, `retry_backoff_factor`, `retry_statuses` – mapped to `faraday-retry`
data/docs/upgrade.md CHANGED
@@ -46,6 +46,28 @@ If a removed option raises an error (`SourceMonitor::DeprecatedOptionError`), yo
46
46
 
47
47
  ## Version-Specific Notes
48
48
 
49
+ ### Upgrading to 0.8.0 (from 0.7.x)
50
+
51
+ **What changed:**
52
+ - Default HTTP User-Agent changed from `SourceMonitor/<version>` to a browser-like string (`Mozilla/5.0 (compatible; SourceMonitor/<version>)`) with Accept-Language, DNT, and Referer headers. This prevents bot-blocking by feed servers.
53
+ - Default `max_in_flight_per_source` changed from `25` to `nil` (unlimited). If you relied on the previous default, add `config.scraping.max_in_flight_per_source = 25` to your initializer.
54
+ - Successful manual health checks on degraded sources now trigger a feed fetch to allow faster recovery.
55
+ - Automatic source favicons via Active Storage (see `config.favicons` section).
56
+ - Toast notifications capped at 3 visible with "+N more" overflow badge and "Clear all" button.
57
+
58
+ **Upgrade steps:**
59
+ ```bash
60
+ bundle update source_monitor
61
+ bin/rails source_monitor:upgrade
62
+ bin/rails db:migrate
63
+ ```
64
+
65
+ **Notes:**
66
+ - No breaking changes for most users. The User-Agent and `max_in_flight_per_source` defaults changed, but both are backward-compatible.
67
+ - If you explicitly set `config.http.user_agent` in your initializer, your custom value is preserved.
68
+ - If your scraping workload requires per-source rate limiting, set `config.scraping.max_in_flight_per_source` explicitly.
69
+ - Favicons require Active Storage in the host app; apps without it see placeholder initials with no errors.
70
+
49
71
  ### Upgrading to 0.4.0 (from 0.3.x)
50
72
 
51
73
  **Released:** 2026-02-12
@@ -70,7 +70,7 @@ SourceMonitor.configure do |config|
70
70
  # config.http.retry_interval = 0.5
71
71
  # config.http.retry_backoff_factor = 2
72
72
  # config.http.retry_statuses = [429, 500, 502, 503, 504]
73
- # Merge extra default headers (User-Agent defaults to SourceMonitor/version).
73
+ # Merge extra default headers (User-Agent defaults to a browser-like string).
74
74
  # config.http.headers = { "X-Request-ID" => -> { SecureRandom.uuid } }
75
75
 
76
76
  # ---- Adaptive fetch scheduling ----------------------------------------
@@ -141,6 +141,20 @@ SourceMonitor.configure do |config|
141
141
  # config.models.source.validate :enforce_custom_rules
142
142
  # config.models.source.validate ->(record) { record.errors.add(:base, "custom error") }
143
143
 
144
+ # ---- Favicons ----------------------------------------------------------
145
+ # Automatically fetch and store source favicons via Active Storage.
146
+ # Requires Active Storage in the host app (rails active_storage:install).
147
+ # Without Active Storage, favicons are silently disabled and colored
148
+ # initials placeholders are shown instead.
149
+ # config.favicons.enabled = true # default: true
150
+ # config.favicons.fetch_timeout = 5 # seconds
151
+ # config.favicons.max_download_size = 1_048_576 # 1 MB
152
+ # config.favicons.retry_cooldown_days = 7
153
+ # config.favicons.allowed_content_types = %w[
154
+ # image/x-icon image/vnd.microsoft.icon image/png
155
+ # image/jpeg image/gif image/svg+xml image/webp
156
+ # ]
157
+
144
158
  # ---- Realtime adapter -------------------------------------------------
145
159
  # Choose the Action Cable backend powering Turbo Streams. Solid Cable keeps
146
160
  # everything in the primary database so Redis is no longer required. Switch