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.
- checksums.yaml +4 -4
- data/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- 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",
|
|
@@ -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
|