source_monitor 0.2.0 → 0.3.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/rails-concern.md +464 -0
  3. data/.claude/agents/rails-controller.md +424 -0
  4. data/.claude/agents/rails-hotwire.md +446 -0
  5. data/.claude/agents/rails-implement.md +374 -0
  6. data/.claude/agents/rails-job.md +334 -0
  7. data/.claude/agents/rails-lint.md +294 -0
  8. data/.claude/agents/rails-mailer.md +371 -0
  9. data/.claude/agents/rails-migration.md +449 -0
  10. data/.claude/agents/rails-model.md +420 -0
  11. data/.claude/agents/rails-policy.md +443 -0
  12. data/.claude/agents/rails-presenter.md +427 -0
  13. data/.claude/agents/rails-query.md +412 -0
  14. data/.claude/agents/rails-review.md +490 -0
  15. data/.claude/agents/rails-service.md +458 -0
  16. data/.claude/agents/rails-state-records.md +465 -0
  17. data/.claude/agents/rails-tdd.md +314 -0
  18. data/.claude/agents/rails-test.md +441 -0
  19. data/.claude/agents/rails-view-component.md +418 -0
  20. data/.claude/hooks/block-secrets.sh +52 -0
  21. data/.claude/settings.json +85 -0
  22. data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
  23. data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
  24. data/.claude/skills/active-storage-setup/SKILL.md +311 -0
  25. data/.claude/skills/api-versioning/SKILL.md +294 -0
  26. data/.claude/skills/authentication-flow/SKILL.md +335 -0
  27. data/.claude/skills/authentication-flow/reference/current.md +248 -0
  28. data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
  29. data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
  30. data/.claude/skills/authorization-pundit/SKILL.md +462 -0
  31. data/.claude/skills/caching-strategies/SKILL.md +350 -0
  32. data/.claude/skills/database-migrations/SKILL.md +354 -0
  33. data/.claude/skills/form-object-patterns/SKILL.md +399 -0
  34. data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
  35. data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
  36. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
  37. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
  38. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
  39. data/.claude/skills/i18n-patterns/SKILL.md +320 -0
  40. data/.claude/skills/install/SKILL.md +367 -0
  41. data/.claude/skills/performance-optimization/SKILL.md +311 -0
  42. data/.claude/skills/rails-architecture/SKILL.md +259 -0
  43. data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
  44. data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
  45. data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
  46. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
  47. data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
  48. data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
  49. data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
  50. data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
  51. data/.claude/skills/rails-concern/SKILL.md +399 -0
  52. data/.claude/skills/rails-controller/SKILL.md +336 -0
  53. data/.claude/skills/rails-model-generator/SKILL.md +321 -0
  54. data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
  55. data/.claude/skills/rails-presenter/SKILL.md +274 -0
  56. data/.claude/skills/rails-query-object/SKILL.md +289 -0
  57. data/.claude/skills/rails-service-object/SKILL.md +349 -0
  58. data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
  59. data/.claude/skills/tdd-cycle/SKILL.md +359 -0
  60. data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
  61. data/.gitignore +1 -0
  62. data/.rubocop.yml +2 -0
  63. data/.ruby-version +1 -1
  64. data/.vbw-planning/.notification-log.jsonl +192 -0
  65. data/.vbw-planning/.session-log.jsonl +871 -0
  66. data/.vbw-planning/PROJECT.md +51 -0
  67. data/.vbw-planning/REQUIREMENTS.md +50 -0
  68. data/.vbw-planning/SHIPPED.md +28 -0
  69. data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
  70. data/.vbw-planning/codebase/CONCERNS.md +99 -0
  71. data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
  72. data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
  73. data/.vbw-planning/codebase/INDEX.md +86 -0
  74. data/.vbw-planning/codebase/META.md +42 -0
  75. data/.vbw-planning/codebase/PATTERNS.md +262 -0
  76. data/.vbw-planning/codebase/STACK.md +101 -0
  77. data/.vbw-planning/codebase/STRUCTURE.md +324 -0
  78. data/.vbw-planning/codebase/TESTING.md +154 -0
  79. data/.vbw-planning/config.json +12 -0
  80. data/.vbw-planning/discovery.json +24 -0
  81. data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
  82. data/.vbw-planning/milestones/default/STATE.md +83 -0
  83. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
  84. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
  85. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
  86. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
  87. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
  88. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
  89. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
  90. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
  91. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
  92. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
  93. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
  94. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
  95. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
  96. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
  97. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
  98. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
  99. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
  100. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
  101. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
  102. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
  103. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
  104. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
  105. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
  106. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
  107. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
  108. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
  109. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
  110. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
  111. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
  112. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
  113. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
  114. data/CHANGELOG.md +28 -0
  115. data/CLAUDE.md +179 -0
  116. data/Gemfile +8 -0
  117. data/Gemfile.lock +114 -101
  118. data/Rakefile +2 -0
  119. data/app/assets/builds/source_monitor/application.css +2076 -0
  120. data/app/assets/builds/source_monitor/application.js +2758 -0
  121. data/app/assets/builds/source_monitor/application.js.map +7 -0
  122. data/app/controllers/source_monitor/application_controller.rb +2 -0
  123. data/app/controllers/source_monitor/health_controller.rb +2 -0
  124. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
  125. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
  126. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
  127. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
  128. data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
  129. data/app/controllers/source_monitor/items_controller.rb +2 -0
  130. data/app/controllers/source_monitor/sources_controller.rb +0 -14
  131. data/app/helpers/source_monitor/application_helper.rb +4 -112
  132. data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
  133. data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
  134. data/app/jobs/source_monitor/application_job.rb +2 -0
  135. data/app/models/source_monitor/application_record.rb +2 -0
  136. data/app/models/source_monitor/log_entry.rb +0 -2
  137. data/config/coverage_baseline.json +217 -1862
  138. data/config/routes.rb +2 -0
  139. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
  140. data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
  141. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
  142. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
  143. data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
  144. data/lib/source_monitor/assets/bundler.rb +2 -0
  145. data/lib/source_monitor/assets.rb +2 -0
  146. data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
  147. data/lib/source_monitor/configuration/events.rb +60 -0
  148. data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
  149. data/lib/source_monitor/configuration/health_settings.rb +27 -0
  150. data/lib/source_monitor/configuration/http_settings.rb +43 -0
  151. data/lib/source_monitor/configuration/model_definition.rb +108 -0
  152. data/lib/source_monitor/configuration/models.rb +36 -0
  153. data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
  154. data/lib/source_monitor/configuration/retention_settings.rb +45 -0
  155. data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
  156. data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
  157. data/lib/source_monitor/configuration/validation_definition.rb +32 -0
  158. data/lib/source_monitor/configuration.rb +12 -579
  159. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
  160. data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
  161. data/lib/source_monitor/dashboard/queries.rb +2 -195
  162. data/lib/source_monitor/engine.rb +2 -0
  163. data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
  164. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
  165. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
  166. data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
  167. data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
  168. data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
  169. data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
  170. data/lib/source_monitor/items/item_creator.rb +28 -455
  171. data/lib/source_monitor/setup/bundle_installer.rb +2 -0
  172. data/lib/source_monitor/setup/cli.rb +2 -0
  173. data/lib/source_monitor/setup/dependency_checker.rb +2 -0
  174. data/lib/source_monitor/setup/detectors.rb +2 -0
  175. data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
  176. data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
  177. data/lib/source_monitor/setup/install_generator.rb +2 -0
  178. data/lib/source_monitor/setup/migration_installer.rb +2 -0
  179. data/lib/source_monitor/setup/node_installer.rb +2 -0
  180. data/lib/source_monitor/setup/prompter.rb +2 -0
  181. data/lib/source_monitor/setup/requirements.rb +2 -0
  182. data/lib/source_monitor/setup/shell_runner.rb +2 -0
  183. data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
  184. data/lib/source_monitor/setup/verification/printer.rb +2 -0
  185. data/lib/source_monitor/setup/verification/result.rb +2 -0
  186. data/lib/source_monitor/setup/verification/runner.rb +2 -0
  187. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
  188. data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
  189. data/lib/source_monitor/setup/workflow.rb +2 -0
  190. data/lib/source_monitor/version.rb +3 -1
  191. data/lib/source_monitor.rb +140 -58
  192. data/lib/tasks/source_monitor_assets.rake +2 -0
  193. data/lib/tasks/source_monitor_setup.rake +2 -0
  194. data/lib/tasks/source_monitor_tasks.rake +2 -0
  195. data/source_monitor.gemspec +3 -1
  196. metadata +144 -4
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ class Queries
6
+ class RecentActivityQuery
7
+ EVENT_TYPE_FETCH = "fetch_log"
8
+ EVENT_TYPE_SCRAPE = "scrape_log"
9
+ EVENT_TYPE_ITEM = "item"
10
+
11
+ def initialize(limit:)
12
+ @limit = limit
13
+ end
14
+
15
+ def call
16
+ rows = connection.exec_query(sanitized_sql)
17
+ rows.map { |row| build_event(row) }
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :limit
23
+
24
+ def connection
25
+ ActiveRecord::Base.connection
26
+ end
27
+
28
+ def build_event(row)
29
+ SourceMonitor::Dashboard::RecentActivity::Event.new(
30
+ type: row["resource_type"].to_sym,
31
+ id: row["resource_id"],
32
+ occurred_at: row["occurred_at"],
33
+ success: row["success_flag"].to_i == 1,
34
+ items_created: row["items_created"],
35
+ items_updated: row["items_updated"],
36
+ scraper_adapter: row["scraper_adapter"],
37
+ item_title: row["item_title"],
38
+ item_url: row["item_url"],
39
+ source_name: row["source_name"],
40
+ source_id: row["source_id"]
41
+ )
42
+ end
43
+
44
+ def sanitized_sql
45
+ ActiveRecord::Base.send(:sanitize_sql_array, [ unified_sql_template, limit ])
46
+ end
47
+
48
+ def unified_sql_template
49
+ <<~SQL
50
+ SELECT resource_type,
51
+ resource_id,
52
+ occurred_at,
53
+ success_flag,
54
+ items_created,
55
+ items_updated,
56
+ scraper_adapter,
57
+ item_title,
58
+ item_url,
59
+ source_name,
60
+ source_id
61
+ FROM (
62
+ #{fetch_log_sql}
63
+ UNION ALL
64
+ #{scrape_log_sql}
65
+ UNION ALL
66
+ #{item_sql}
67
+ ) AS dashboard_events
68
+ WHERE occurred_at IS NOT NULL
69
+ ORDER BY occurred_at DESC
70
+ LIMIT ?
71
+ SQL
72
+ end
73
+
74
+ def fetch_log_sql
75
+ <<~SQL
76
+ SELECT
77
+ '#{EVENT_TYPE_FETCH}' AS resource_type,
78
+ #{SourceMonitor::FetchLog.quoted_table_name}.id AS resource_id,
79
+ #{SourceMonitor::FetchLog.quoted_table_name}.started_at AS occurred_at,
80
+ CASE WHEN #{SourceMonitor::FetchLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
81
+ #{SourceMonitor::FetchLog.quoted_table_name}.items_created AS items_created,
82
+ #{SourceMonitor::FetchLog.quoted_table_name}.items_updated AS items_updated,
83
+ NULL AS scraper_adapter,
84
+ NULL AS item_title,
85
+ NULL AS item_url,
86
+ NULL AS source_name,
87
+ #{SourceMonitor::FetchLog.quoted_table_name}.source_id AS source_id
88
+ FROM #{SourceMonitor::FetchLog.quoted_table_name}
89
+ SQL
90
+ end
91
+
92
+ def scrape_log_sql
93
+ <<~SQL
94
+ SELECT
95
+ '#{EVENT_TYPE_SCRAPE}' AS resource_type,
96
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.id AS resource_id,
97
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.started_at AS occurred_at,
98
+ CASE WHEN #{SourceMonitor::ScrapeLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
99
+ NULL AS items_created,
100
+ NULL AS items_updated,
101
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.scraper_adapter AS scraper_adapter,
102
+ NULL AS item_title,
103
+ NULL AS item_url,
104
+ #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
105
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id AS source_id
106
+ FROM #{SourceMonitor::ScrapeLog.quoted_table_name}
107
+ LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
108
+ ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id
109
+ SQL
110
+ end
111
+
112
+ def item_sql
113
+ <<~SQL
114
+ SELECT
115
+ '#{EVENT_TYPE_ITEM}' AS resource_type,
116
+ #{SourceMonitor::Item.quoted_table_name}.id AS resource_id,
117
+ #{SourceMonitor::Item.quoted_table_name}.created_at AS occurred_at,
118
+ 1 AS success_flag,
119
+ NULL AS items_created,
120
+ NULL AS items_updated,
121
+ NULL AS scraper_adapter,
122
+ #{SourceMonitor::Item.quoted_table_name}.title AS item_title,
123
+ #{SourceMonitor::Item.quoted_table_name}.url AS item_url,
124
+ #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
125
+ #{SourceMonitor::Item.quoted_table_name}.source_id AS source_id
126
+ FROM #{SourceMonitor::Item.quoted_table_name}
127
+ LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
128
+ ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::Item.quoted_table_name}.source_id
129
+ SQL
130
+ end
131
+
132
+ def quoted_source_name
133
+ ActiveRecord::Base.connection.quote_column_name("name")
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ class Queries
6
+ class StatsQuery
7
+ def initialize(reference_time:)
8
+ @reference_time = reference_time
9
+ end
10
+
11
+ def call
12
+ {
13
+ total_sources: integer_value(source_counts["total_sources"]),
14
+ active_sources: integer_value(source_counts["active_sources"]),
15
+ failed_sources: integer_value(source_counts["failed_sources"]),
16
+ total_items: total_items_count,
17
+ fetches_today: fetches_today_count
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :reference_time
24
+
25
+ def source_counts
26
+ @source_counts ||= begin
27
+ SourceMonitor::Source.connection.exec_query(source_counts_sql).first || {}
28
+ end
29
+ end
30
+
31
+ def total_items_count
32
+ SourceMonitor::Item.connection.select_value(total_items_sql).to_i
33
+ end
34
+
35
+ def fetches_today_count
36
+ SourceMonitor::FetchLog.where("started_at >= ?", start_of_day).count
37
+ end
38
+
39
+ def source_counts_sql
40
+ <<~SQL.squish
41
+ SELECT
42
+ COUNT(*) AS total_sources,
43
+ SUM(CASE WHEN active THEN 1 ELSE 0 END) AS active_sources,
44
+ SUM(CASE WHEN (#{failure_condition}) THEN 1 ELSE 0 END) AS failed_sources
45
+ FROM #{SourceMonitor::Source.quoted_table_name}
46
+ SQL
47
+ end
48
+
49
+ def failure_condition
50
+ [
51
+ "#{SourceMonitor::Source.quoted_table_name}.failure_count > 0",
52
+ "#{SourceMonitor::Source.quoted_table_name}.last_error IS NOT NULL",
53
+ "#{SourceMonitor::Source.quoted_table_name}.last_error_at IS NOT NULL"
54
+ ].join(" OR ")
55
+ end
56
+
57
+ def total_items_sql
58
+ "SELECT COUNT(*) FROM #{SourceMonitor::Item.quoted_table_name}"
59
+ end
60
+
61
+ def start_of_day
62
+ reference_time.in_time_zone.beginning_of_day
63
+ end
64
+
65
+ def integer_value(value)
66
+ value.to_i
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "active_support/notifications"
4
4
  require "source_monitor/dashboard/upcoming_fetch_schedule"
5
+ require "source_monitor/dashboard/queries/stats_query"
6
+ require "source_monitor/dashboard/queries/recent_activity_query"
5
7
 
6
8
  module SourceMonitor
7
9
  module Dashboard
@@ -139,201 +141,6 @@ module SourceMonitor
139
141
  attr_reader :store
140
142
  end
141
143
 
142
- class StatsQuery
143
- def initialize(reference_time:)
144
- @reference_time = reference_time
145
- end
146
-
147
- def call
148
- {
149
- total_sources: integer_value(source_counts["total_sources"]),
150
- active_sources: integer_value(source_counts["active_sources"]),
151
- failed_sources: integer_value(source_counts["failed_sources"]),
152
- total_items: total_items_count,
153
- fetches_today: fetches_today_count
154
- }
155
- end
156
-
157
- private
158
-
159
- attr_reader :reference_time
160
-
161
- def source_counts
162
- @source_counts ||= begin
163
- SourceMonitor::Source.connection.exec_query(source_counts_sql).first || {}
164
- end
165
- end
166
-
167
- def total_items_count
168
- SourceMonitor::Item.connection.select_value(total_items_sql).to_i
169
- end
170
-
171
- def fetches_today_count
172
- SourceMonitor::FetchLog.where("started_at >= ?", start_of_day).count
173
- end
174
-
175
- def source_counts_sql
176
- <<~SQL.squish
177
- SELECT
178
- COUNT(*) AS total_sources,
179
- SUM(CASE WHEN active THEN 1 ELSE 0 END) AS active_sources,
180
- SUM(CASE WHEN (#{failure_condition}) THEN 1 ELSE 0 END) AS failed_sources
181
- FROM #{SourceMonitor::Source.quoted_table_name}
182
- SQL
183
- end
184
-
185
- def failure_condition
186
- [
187
- "#{SourceMonitor::Source.quoted_table_name}.failure_count > 0",
188
- "#{SourceMonitor::Source.quoted_table_name}.last_error IS NOT NULL",
189
- "#{SourceMonitor::Source.quoted_table_name}.last_error_at IS NOT NULL"
190
- ].join(" OR ")
191
- end
192
-
193
- def total_items_sql
194
- "SELECT COUNT(*) FROM #{SourceMonitor::Item.quoted_table_name}"
195
- end
196
-
197
- def start_of_day
198
- reference_time.in_time_zone.beginning_of_day
199
- end
200
-
201
- def integer_value(value)
202
- value.to_i
203
- end
204
- end
205
-
206
- class RecentActivityQuery
207
- EVENT_TYPE_FETCH = "fetch_log"
208
- EVENT_TYPE_SCRAPE = "scrape_log"
209
- EVENT_TYPE_ITEM = "item"
210
-
211
- def initialize(limit:)
212
- @limit = limit
213
- end
214
-
215
- def call
216
- rows = connection.exec_query(sanitized_sql)
217
- rows.map { |row| build_event(row) }
218
- end
219
-
220
- private
221
-
222
- attr_reader :limit
223
-
224
- def connection
225
- ActiveRecord::Base.connection
226
- end
227
-
228
- def build_event(row)
229
- SourceMonitor::Dashboard::RecentActivity::Event.new(
230
- type: row["resource_type"].to_sym,
231
- id: row["resource_id"],
232
- occurred_at: row["occurred_at"],
233
- success: row["success_flag"].to_i == 1,
234
- items_created: row["items_created"],
235
- items_updated: row["items_updated"],
236
- scraper_adapter: row["scraper_adapter"],
237
- item_title: row["item_title"],
238
- item_url: row["item_url"],
239
- source_name: row["source_name"],
240
- source_id: row["source_id"]
241
- )
242
- end
243
-
244
- def sanitized_sql
245
- ActiveRecord::Base.send(:sanitize_sql_array, [ unified_sql_template, limit ])
246
- end
247
-
248
- def unified_sql_template
249
- <<~SQL
250
- SELECT resource_type,
251
- resource_id,
252
- occurred_at,
253
- success_flag,
254
- items_created,
255
- items_updated,
256
- scraper_adapter,
257
- item_title,
258
- item_url,
259
- source_name,
260
- source_id
261
- FROM (
262
- #{fetch_log_sql}
263
- UNION ALL
264
- #{scrape_log_sql}
265
- UNION ALL
266
- #{item_sql}
267
- ) AS dashboard_events
268
- WHERE occurred_at IS NOT NULL
269
- ORDER BY occurred_at DESC
270
- LIMIT ?
271
- SQL
272
- end
273
-
274
- def fetch_log_sql
275
- <<~SQL
276
- SELECT
277
- '#{EVENT_TYPE_FETCH}' AS resource_type,
278
- #{SourceMonitor::FetchLog.quoted_table_name}.id AS resource_id,
279
- #{SourceMonitor::FetchLog.quoted_table_name}.started_at AS occurred_at,
280
- CASE WHEN #{SourceMonitor::FetchLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
281
- #{SourceMonitor::FetchLog.quoted_table_name}.items_created AS items_created,
282
- #{SourceMonitor::FetchLog.quoted_table_name}.items_updated AS items_updated,
283
- NULL AS scraper_adapter,
284
- NULL AS item_title,
285
- NULL AS item_url,
286
- NULL AS source_name,
287
- #{SourceMonitor::FetchLog.quoted_table_name}.source_id AS source_id
288
- FROM #{SourceMonitor::FetchLog.quoted_table_name}
289
- SQL
290
- end
291
-
292
- def scrape_log_sql
293
- <<~SQL
294
- SELECT
295
- '#{EVENT_TYPE_SCRAPE}' AS resource_type,
296
- #{SourceMonitor::ScrapeLog.quoted_table_name}.id AS resource_id,
297
- #{SourceMonitor::ScrapeLog.quoted_table_name}.started_at AS occurred_at,
298
- CASE WHEN #{SourceMonitor::ScrapeLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
299
- NULL AS items_created,
300
- NULL AS items_updated,
301
- #{SourceMonitor::ScrapeLog.quoted_table_name}.scraper_adapter AS scraper_adapter,
302
- NULL AS item_title,
303
- NULL AS item_url,
304
- #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
305
- #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id AS source_id
306
- FROM #{SourceMonitor::ScrapeLog.quoted_table_name}
307
- LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
308
- ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id
309
- SQL
310
- end
311
-
312
- def item_sql
313
- <<~SQL
314
- SELECT
315
- '#{EVENT_TYPE_ITEM}' AS resource_type,
316
- #{SourceMonitor::Item.quoted_table_name}.id AS resource_id,
317
- #{SourceMonitor::Item.quoted_table_name}.created_at AS occurred_at,
318
- 1 AS success_flag,
319
- NULL AS items_created,
320
- NULL AS items_updated,
321
- NULL AS scraper_adapter,
322
- #{SourceMonitor::Item.quoted_table_name}.title AS item_title,
323
- #{SourceMonitor::Item.quoted_table_name}.url AS item_url,
324
- #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
325
- #{SourceMonitor::Item.quoted_table_name}.source_id AS source_id
326
- FROM #{SourceMonitor::Item.quoted_table_name}
327
- LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
328
- ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::Item.quoted_table_name}.source_id
329
- SQL
330
- end
331
-
332
- def quoted_source_name
333
- ActiveRecord::Base.connection.quote_column_name("name")
334
- end
335
- end
336
-
337
144
  QUICK_ACTIONS = [
338
145
  SourceMonitor::Dashboard::QuickAction.new(
339
146
  label: "Add Source",
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SourceMonitor
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace SourceMonitor
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Fetching
5
+ class FeedFetcher
6
+ class AdaptiveInterval
7
+ MIN_FETCH_INTERVAL = 5.minutes.to_f
8
+ MAX_FETCH_INTERVAL = 24.hours.to_f
9
+ INCREASE_FACTOR = 1.25
10
+ DECREASE_FACTOR = 0.75
11
+ FAILURE_INCREASE_FACTOR = 1.5
12
+ JITTER_PERCENT = 0.1
13
+
14
+ attr_reader :source, :jitter_proc
15
+
16
+ def initialize(source:, jitter_proc: nil)
17
+ @source = source
18
+ @jitter_proc = jitter_proc
19
+ end
20
+
21
+ def apply_adaptive_interval!(attributes, content_changed:, failure: false)
22
+ if source.adaptive_fetching_enabled?
23
+ interval_seconds = compute_next_interval_seconds(content_changed:, failure:)
24
+ scheduled_time = Time.current + adjusted_interval_with_jitter(interval_seconds)
25
+ scheduled_time = [ scheduled_time, source.backoff_until ].compact.max if source.backoff_until.present?
26
+
27
+ attributes[:fetch_interval_minutes] = interval_minutes_for(interval_seconds)
28
+ attributes[:next_fetch_at] = scheduled_time
29
+ attributes[:backoff_until] = failure ? scheduled_time : nil
30
+ else
31
+ fixed_minutes = [ source.fetch_interval_minutes.to_i, 1 ].max
32
+ attributes[:next_fetch_at] = Time.current + fixed_minutes.minutes
33
+ attributes[:backoff_until] = nil
34
+ end
35
+ end
36
+
37
+ def compute_next_interval_seconds(content_changed:, failure:)
38
+ current = [ current_interval_seconds, min_fetch_interval_seconds ].max
39
+
40
+ next_interval = if failure
41
+ current * failure_increase_factor_value
42
+ elsif content_changed
43
+ current * decrease_factor_value
44
+ else
45
+ current * increase_factor_value
46
+ end
47
+
48
+ next_interval = min_fetch_interval_seconds if next_interval < min_fetch_interval_seconds
49
+ next_interval = max_fetch_interval_seconds if next_interval > max_fetch_interval_seconds
50
+ next_interval.to_f
51
+ end
52
+
53
+ def adjusted_interval_with_jitter(interval_seconds)
54
+ jitter = jitter_offset(interval_seconds)
55
+ adjusted = interval_seconds + jitter
56
+ adjusted = min_fetch_interval_seconds if adjusted < min_fetch_interval_seconds
57
+ adjusted
58
+ end
59
+
60
+ def jitter_offset(interval_seconds)
61
+ return 0 if interval_seconds <= 0
62
+ return jitter_proc.call(interval_seconds) if jitter_proc.respond_to?(:call)
63
+
64
+ jitter_range = interval_seconds * jitter_percent_value
65
+ return 0 if jitter_range <= 0
66
+
67
+ ((rand * 2) - 1) * jitter_range
68
+ end
69
+
70
+ def interval_minutes_for(interval_seconds)
71
+ minutes = (interval_seconds / 60.0).round
72
+ [ minutes, 1 ].max
73
+ end
74
+
75
+ def configured_seconds(minutes_value, default)
76
+ minutes = extract_numeric(minutes_value)
77
+ return default unless minutes && minutes.positive?
78
+
79
+ minutes * 60.0
80
+ end
81
+
82
+ def configured_positive(value, default)
83
+ number = extract_numeric(value)
84
+ return default unless number && number.positive?
85
+
86
+ number
87
+ end
88
+
89
+ def configured_non_negative(value, default)
90
+ number = extract_numeric(value)
91
+ return default if number.nil?
92
+
93
+ number.negative? ? 0.0 : number
94
+ end
95
+
96
+ def extract_numeric(value)
97
+ return value if value.is_a?(Numeric)
98
+ return value.to_f if value.respond_to?(:to_f)
99
+
100
+ nil
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ private
106
+
107
+ def current_interval_seconds
108
+ source.fetch_interval_minutes.to_f * 60.0
109
+ end
110
+
111
+ def min_fetch_interval_seconds
112
+ configured_seconds(fetching_config&.min_interval_minutes, MIN_FETCH_INTERVAL)
113
+ end
114
+
115
+ def max_fetch_interval_seconds
116
+ configured_seconds(fetching_config&.max_interval_minutes, MAX_FETCH_INTERVAL)
117
+ end
118
+
119
+ def increase_factor_value
120
+ configured_positive(fetching_config&.increase_factor, INCREASE_FACTOR)
121
+ end
122
+
123
+ def decrease_factor_value
124
+ configured_positive(fetching_config&.decrease_factor, DECREASE_FACTOR)
125
+ end
126
+
127
+ def failure_increase_factor_value
128
+ configured_positive(fetching_config&.failure_increase_factor, FAILURE_INCREASE_FACTOR)
129
+ end
130
+
131
+ def jitter_percent_value
132
+ configured_non_negative(fetching_config&.jitter_percent, JITTER_PERCENT)
133
+ end
134
+
135
+ def fetching_config
136
+ SourceMonitor.config.fetching
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Fetching
5
+ class FeedFetcher
6
+ class EntryProcessor
7
+ attr_reader :source
8
+
9
+ def initialize(source:)
10
+ @source = source
11
+ end
12
+
13
+ def process_feed_entries(feed)
14
+ return FeedFetcher::EntryProcessingResult.new(
15
+ created: 0,
16
+ updated: 0,
17
+ failed: 0,
18
+ items: [],
19
+ errors: [],
20
+ created_items: [],
21
+ updated_items: []
22
+ ) unless feed.respond_to?(:entries)
23
+
24
+ created = 0
25
+ updated = 0
26
+ failed = 0
27
+ items = []
28
+ created_items = []
29
+ updated_items = []
30
+ errors = []
31
+
32
+ Array(feed.entries).each do |entry|
33
+ begin
34
+ result = SourceMonitor::Items::ItemCreator.call(source:, entry:)
35
+ SourceMonitor::Events.run_item_processors(source:, entry:, result: result)
36
+ items << result.item
37
+ if result.created?
38
+ created += 1
39
+ created_items << result.item
40
+ SourceMonitor::Events.after_item_created(item: result.item, source:, entry:, result: result)
41
+ else
42
+ updated += 1
43
+ updated_items << result.item
44
+ end
45
+ rescue StandardError => error
46
+ failed += 1
47
+ errors << normalize_item_error(entry, error)
48
+ end
49
+ end
50
+
51
+ FeedFetcher::EntryProcessingResult.new(
52
+ created:,
53
+ updated:,
54
+ failed:,
55
+ items:,
56
+ errors: errors.compact,
57
+ created_items:,
58
+ updated_items:
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_item_error(entry, error)
65
+ {
66
+ guid: safe_entry_guid(entry),
67
+ title: safe_entry_title(entry),
68
+ error_class: error.class.name,
69
+ error_message: error.message
70
+ }
71
+ rescue StandardError
72
+ { error_class: error.class.name, error_message: error.message }
73
+ end
74
+
75
+ def safe_entry_guid(entry)
76
+ if entry.respond_to?(:entry_id)
77
+ entry.entry_id
78
+ elsif entry.respond_to?(:id)
79
+ entry.id
80
+ end
81
+ end
82
+
83
+ def safe_entry_title(entry)
84
+ entry.title if entry.respond_to?(:title)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end