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.
- checksums.yaml +4 -4
- data/.claude/skills/sm-configure/SKILL.md +10 -1
- data/.claude/skills/sm-configure/reference/configuration-reference.md +44 -0
- data/.claude/skills/sm-host-setup/reference/initializer-template.md +17 -0
- data/.claude/skills/sm-host-setup/reference/setup-checklist.md +2 -0
- data/.claude/skills/sm-job/reference/job-conventions.md +26 -0
- data/.claude/skills/sm-upgrade/reference/version-history.md +22 -0
- data/.gitignore +10 -0
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +35 -0
- data/CLAUDE.md +11 -5
- data/Gemfile.lock +1 -1
- data/README.md +6 -4
- data/VERSION +1 -1
- data/app/assets/builds/source_monitor/application.css +43 -0
- data/app/assets/builds/source_monitor/application.js +127 -0
- data/app/assets/builds/source_monitor/application.js.map +3 -3
- data/app/assets/javascripts/source_monitor/application.js +2 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_container_controller.js +138 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +11 -0
- data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +38 -0
- data/app/controllers/source_monitor/sources_controller.rb +11 -0
- data/app/helpers/source_monitor/application_helper.rb +51 -0
- data/app/jobs/source_monitor/favicon_fetch_job.rb +71 -0
- data/app/jobs/source_monitor/import_opml_job.rb +9 -0
- data/app/jobs/source_monitor/source_health_check_job.rb +10 -0
- data/app/models/source_monitor/source.rb +2 -0
- data/app/views/layouts/source_monitor/application.html.erb +23 -2
- data/app/views/source_monitor/shared/_toast.html.erb +1 -0
- data/app/views/source_monitor/sources/_details.html.erb +34 -5
- data/app/views/source_monitor/sources/_row.html.erb +11 -6
- data/config/routes.rb +1 -0
- data/docs/configuration.md +1 -1
- data/docs/upgrade.md +22 -0
- data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +15 -1
- data/lib/source_monitor/configuration/favicons_settings.rb +42 -0
- data/lib/source_monitor/configuration/http_settings.rb +1 -1
- data/lib/source_monitor/configuration/scraping_settings.rb +1 -1
- data/lib/source_monitor/configuration.rb +3 -1
- data/lib/source_monitor/favicons/discoverer.rb +196 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +21 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +1 -0
- data/lib/source_monitor/http.rb +5 -3
- data/lib/source_monitor/version.rb +1 -1
- data/lib/source_monitor.rb +4 -0
- data/source_monitor.gemspec +1 -1
- metadata +6 -106
- data/.vbw-planning/PROJECT.md +0 -51
- data/.vbw-planning/ROADMAP.md +0 -53
- data/.vbw-planning/SHIPPED.md +0 -63
- data/.vbw-planning/STATE.md +0 -27
- data/.vbw-planning/codebase/ARCHITECTURE.md +0 -147
- data/.vbw-planning/codebase/CONCERNS.md +0 -99
- data/.vbw-planning/codebase/CONVENTIONS.md +0 -97
- data/.vbw-planning/codebase/DEPENDENCIES.md +0 -100
- data/.vbw-planning/codebase/INDEX.md +0 -86
- data/.vbw-planning/codebase/META.md +0 -42
- data/.vbw-planning/codebase/PATTERNS.md +0 -262
- data/.vbw-planning/codebase/STACK.md +0 -101
- data/.vbw-planning/codebase/STRUCTURE.md +0 -324
- data/.vbw-planning/codebase/TESTING.md +0 -154
- data/.vbw-planning/config.json +0 -53
- data/.vbw-planning/discovery.json +0 -26
- data/.vbw-planning/milestones/default/ROADMAP.md +0 -115
- data/.vbw-planning/milestones/default/STATE.md +0 -82
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +0 -56
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +0 -187
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +0 -64
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +0 -137
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +0 -67
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +0 -142
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +0 -64
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +0 -138
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +0 -85
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +0 -147
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +0 -63
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +0 -129
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +0 -74
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +0 -154
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +0 -303
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +0 -510
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +0 -61
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +0 -161
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +0 -66
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +0 -132
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +0 -59
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +0 -171
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +0 -56
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +0 -152
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +0 -33
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +0 -42
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +0 -119
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +0 -52
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +0 -195
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +0 -79
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +0 -130
- data/.vbw-planning/milestones/generator-enhancements/REQUIREMENTS.md +0 -72
- data/.vbw-planning/milestones/generator-enhancements/ROADMAP.md +0 -125
- data/.vbw-planning/milestones/generator-enhancements/SHIPPED.md +0 -40
- data/.vbw-planning/milestones/generator-enhancements/STATE.md +0 -43
- data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-CONTEXT.md +0 -33
- data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-VERIFICATION.md +0 -86
- data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01-SUMMARY.md +0 -61
- data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01.md +0 -380
- data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/02-VERIFICATION.md +0 -78
- data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01-SUMMARY.md +0 -46
- data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01.md +0 -500
- data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/03-VERIFICATION.md +0 -89
- data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01-SUMMARY.md +0 -48
- data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01.md +0 -456
- data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/04-VERIFICATION.md +0 -129
- data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01-SUMMARY.md +0 -70
- data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01.md +0 -747
- data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/05-VERIFICATION.md +0 -156
- data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01-SUMMARY.md +0 -69
- data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01.md +0 -455
- data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02-SUMMARY.md +0 -39
- data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02.md +0 -488
- data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/06-VERIFICATION.md +0 -100
- data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md +0 -37
- data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01.md +0 -345
- data/.vbw-planning/milestones/upgrade-assurance/REQUIREMENTS.md +0 -80
- data/.vbw-planning/milestones/upgrade-assurance/ROADMAP.md +0 -75
- data/.vbw-planning/milestones/upgrade-assurance/STATE.md +0 -29
- data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/01-VERIFICATION.md +0 -144
- data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01-SUMMARY.md +0 -43
- data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01.md +0 -405
- data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01-SUMMARY.md +0 -27
- data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01.md +0 -303
- data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/03-VERIFICATION.md +0 -380
- data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01-SUMMARY.md +0 -36
- data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01.md +0 -652
- data/.vbw-planning/phases/01-aia-certificate-resolution/.context-dev.md +0 -17
- data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01-SUMMARY.md +0 -26
- data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01.md +0 -71
- data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02-SUMMARY.md +0 -16
- data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02.md +0 -56
- data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03-SUMMARY.md +0 -17
- data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03.md +0 -98
- data/.vbw-planning/phases/02-test-performance/.context-dev.md +0 -75
- data/.vbw-planning/phases/02-test-performance/.context-lead.md +0 -89
- data/.vbw-planning/phases/02-test-performance/.context-qa.md +0 -23
- data/.vbw-planning/phases/02-test-performance/02-RESEARCH.md +0 -56
- data/.vbw-planning/phases/02-test-performance/02-VERIFICATION.md +0 -51
- data/.vbw-planning/phases/02-test-performance/PLAN-01-SUMMARY.md +0 -37
- data/.vbw-planning/phases/02-test-performance/PLAN-01.md +0 -156
- data/.vbw-planning/phases/02-test-performance/PLAN-02-SUMMARY.md +0 -33
- data/.vbw-planning/phases/02-test-performance/PLAN-02.md +0 -120
- data/.vbw-planning/phases/02-test-performance/PLAN-03-SUMMARY.md +0 -30
- data/.vbw-planning/phases/02-test-performance/PLAN-03.md +0 -154
- data/.vbw-planning/phases/02-test-performance/PLAN-04-SUMMARY.md +0 -28
- 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
|
-
|
|
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
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
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">·</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="
|
|
27
|
-
<%=
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|