source_monitor 0.7.1 → 0.8.1
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/commands/release.md +18 -6
- 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 +45 -0
- data/CLAUDE.md +24 -3
- data/Gemfile.lock +1 -1
- data/README.md +6 -4
- data/Rakefile +0 -2
- 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/import_sessions/steps/_preview.html.erb +7 -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">
|
|
@@ -60,6 +60,11 @@
|
|
|
60
60
|
data: { turbo: false, action: "confirm-navigation#disable" },
|
|
61
61
|
html: { class: "block" } do |form| %>
|
|
62
62
|
<%= hidden_field_tag :filter, @filter %>
|
|
63
|
+
<%# Preserve selections from other pages so form submission includes ALL selected IDs %>
|
|
64
|
+
<% visible_ids = @paginated_entries.map { |e| e[:id] } %>
|
|
65
|
+
<% @preview_entries.select { |e| e[:selected] && !visible_ids.include?(e[:id]) }.each do |entry| %>
|
|
66
|
+
<%= hidden_field_tag "import_session[selected_source_ids][]", entry[:id] %>
|
|
67
|
+
<% end %>
|
|
63
68
|
<div class="overflow-x-auto">
|
|
64
69
|
<table class="min-w-full divide-y divide-slate-200 text-left text-sm" data-controller="select-all">
|
|
65
70
|
<thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
@@ -131,7 +136,7 @@
|
|
|
131
136
|
<%= link_to "Previous",
|
|
132
137
|
source_monitor.step_import_session_path(import_session, step: "preview", **prev_params),
|
|
133
138
|
class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50",
|
|
134
|
-
data: { turbo_frame: "import_session_step" } %>
|
|
139
|
+
data: { turbo: true, turbo_frame: "import_session_step" } %>
|
|
135
140
|
<% else %>
|
|
136
141
|
<span class="inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-300">Previous</span>
|
|
137
142
|
<% end %>
|
|
@@ -140,7 +145,7 @@
|
|
|
140
145
|
<%= link_to "Next",
|
|
141
146
|
source_monitor.step_import_session_path(import_session, step: "preview", **next_params),
|
|
142
147
|
class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50",
|
|
143
|
-
data: { turbo_frame: "import_session_step" } %>
|
|
148
|
+
data: { turbo: true, turbo_frame: "import_session_step" } %>
|
|
144
149
|
<% else %>
|
|
145
150
|
<span class="inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-300">Next</span>
|
|
146
151
|
<% end %>
|
|
@@ -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
|