source_monitor 0.3.3 → 0.5.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/release.md +101 -58
  3. data/.claude/skills/sm-configure/SKILL.md +13 -2
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +33 -0
  5. data/.claude/skills/sm-host-setup/SKILL.md +18 -2
  6. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +33 -0
  7. data/.claude/skills/sm-job/SKILL.md +1 -1
  8. data/.claude/skills/sm-upgrade/SKILL.md +102 -0
  9. data/.claude/skills/sm-upgrade/reference/upgrade-workflow.md +92 -0
  10. data/.claude/skills/sm-upgrade/reference/version-history.md +68 -0
  11. data/.vbw-planning/SHIPPED.md +35 -0
  12. data/.vbw-planning/config.json +24 -1
  13. data/.vbw-planning/discovery.json +3 -1
  14. data/.vbw-planning/{REQUIREMENTS.md → milestones/generator-enhancements/REQUIREMENTS.md} +22 -0
  15. data/.vbw-planning/milestones/generator-enhancements/ROADMAP.md +125 -0
  16. data/.vbw-planning/milestones/generator-enhancements/SHIPPED.md +40 -0
  17. data/.vbw-planning/milestones/generator-enhancements/STATE.md +43 -0
  18. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-CONTEXT.md +33 -0
  19. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-VERIFICATION.md +86 -0
  20. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01-SUMMARY.md +61 -0
  21. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01.md +380 -0
  22. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/02-VERIFICATION.md +78 -0
  23. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01-SUMMARY.md +46 -0
  24. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01.md +500 -0
  25. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/03-VERIFICATION.md +89 -0
  26. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01-SUMMARY.md +48 -0
  27. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01.md +456 -0
  28. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/04-VERIFICATION.md +129 -0
  29. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01-SUMMARY.md +70 -0
  30. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01.md +747 -0
  31. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/05-VERIFICATION.md +156 -0
  32. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01-SUMMARY.md +69 -0
  33. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01.md +455 -0
  34. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02-SUMMARY.md +39 -0
  35. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02.md +488 -0
  36. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/06-VERIFICATION.md +100 -0
  37. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md +37 -0
  38. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01.md +345 -0
  39. data/.vbw-planning/milestones/upgrade-assurance/REQUIREMENTS.md +80 -0
  40. data/.vbw-planning/milestones/upgrade-assurance/ROADMAP.md +75 -0
  41. data/.vbw-planning/milestones/upgrade-assurance/STATE.md +29 -0
  42. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/01-VERIFICATION.md +144 -0
  43. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01-SUMMARY.md +43 -0
  44. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01.md +405 -0
  45. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01-SUMMARY.md +27 -0
  46. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01.md +303 -0
  47. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/03-VERIFICATION.md +380 -0
  48. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01-SUMMARY.md +36 -0
  49. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01.md +652 -0
  50. data/CHANGELOG.md +48 -0
  51. data/CLAUDE.md +5 -3
  52. data/Gemfile.lock +1 -1
  53. data/VERSION +1 -1
  54. data/app/assets/builds/source_monitor/application.css +9 -0
  55. data/app/helpers/source_monitor/application_helper.rb +38 -0
  56. data/app/jobs/source_monitor/download_content_images_job.rb +72 -0
  57. data/app/models/source_monitor/item_content.rb +2 -0
  58. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +9 -0
  59. data/app/views/source_monitor/items/_details.html.erb +2 -2
  60. data/app/views/source_monitor/logs/index.html.erb +9 -0
  61. data/app/views/source_monitor/sources/_details.html.erb +2 -2
  62. data/app/views/source_monitor/sources/_row.html.erb +1 -1
  63. data/docs/setup.md +10 -1
  64. data/docs/troubleshooting.md +38 -7
  65. data/docs/upgrade.md +140 -0
  66. data/lib/generators/source_monitor/install/install_generator.rb +101 -0
  67. data/lib/source_monitor/configuration/deprecation_registry.rb +237 -0
  68. data/lib/source_monitor/configuration/http_settings.rb +7 -1
  69. data/lib/source_monitor/configuration/images_settings.rb +37 -0
  70. data/lib/source_monitor/configuration.rb +11 -1
  71. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +16 -7
  72. data/lib/source_monitor/dashboard/recent_activity.rb +1 -0
  73. data/lib/source_monitor/dashboard/recent_activity_presenter.rb +15 -2
  74. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +13 -0
  75. data/lib/source_monitor/http.rb +23 -0
  76. data/lib/source_monitor/images/content_rewriter.rb +81 -0
  77. data/lib/source_monitor/images/downloader.rb +82 -0
  78. data/lib/source_monitor/logs/table_presenter.rb +25 -0
  79. data/lib/source_monitor/setup/cli.rb +7 -0
  80. data/lib/source_monitor/setup/procfile_patcher.rb +31 -0
  81. data/lib/source_monitor/setup/queue_config_patcher.rb +84 -0
  82. data/lib/source_monitor/setup/skills_installer.rb +1 -0
  83. data/lib/source_monitor/setup/upgrade_command.rb +59 -0
  84. data/lib/source_monitor/setup/verification/pending_migrations_verifier.rb +92 -0
  85. data/lib/source_monitor/setup/verification/recurring_schedule_verifier.rb +102 -0
  86. data/lib/source_monitor/setup/verification/runner.rb +1 -1
  87. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +1 -1
  88. data/lib/source_monitor/setup/workflow.rb +10 -0
  89. data/lib/source_monitor/version.rb +1 -1
  90. data/lib/source_monitor.rb +11 -0
  91. metadata +51 -2
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokolexbor"
4
+ require "uri"
5
+
6
+ module SourceMonitor
7
+ module Images
8
+ class ContentRewriter
9
+ attr_reader :html, :base_url
10
+
11
+ def initialize(html, base_url: nil)
12
+ @html = html.to_s
13
+ @base_url = base_url
14
+ end
15
+
16
+ # Returns an array of absolute image URLs found in <img> tags.
17
+ # Skips data: URIs, blank src, and invalid URLs.
18
+ def image_urls
19
+ return [] if html.blank?
20
+
21
+ doc = parse_fragment
22
+ urls = []
23
+
24
+ doc.css("img[src]").each do |img|
25
+ url = resolve_url(img["src"])
26
+ urls << url if url && downloadable_url?(url)
27
+ end
28
+
29
+ urls.uniq
30
+ end
31
+
32
+ # Rewrites <img src="..."> attributes by yielding each original URL
33
+ # to the block and replacing with the block's return value.
34
+ # Returns the rewritten HTML string.
35
+ # If the block returns nil, the original URL is preserved (graceful fallback).
36
+ def rewrite
37
+ return html if html.blank?
38
+
39
+ doc = parse_fragment
40
+
41
+ doc.css("img[src]").each do |img|
42
+ original_url = resolve_url(img["src"])
43
+ next unless original_url && downloadable_url?(original_url)
44
+
45
+ new_url = yield(original_url)
46
+ img["src"] = new_url if new_url.present?
47
+ end
48
+
49
+ doc.to_html
50
+ end
51
+
52
+ private
53
+
54
+ def parse_fragment
55
+ Nokolexbor::DocumentFragment.parse(html)
56
+ end
57
+
58
+ def resolve_url(src)
59
+ src = src.to_s.strip
60
+ return nil if src.blank?
61
+ return nil if src.start_with?("data:")
62
+
63
+ uri = URI.parse(src)
64
+ if uri.relative? && base_url.present?
65
+ URI.join(base_url, src).to_s
66
+ elsif uri.absolute?
67
+ src
68
+ end
69
+ rescue URI::InvalidURIError
70
+ nil
71
+ end
72
+
73
+ def downloadable_url?(url)
74
+ uri = URI.parse(url)
75
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
76
+ rescue URI::InvalidURIError
77
+ false
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "securerandom"
5
+
6
+ module SourceMonitor
7
+ module Images
8
+ class Downloader
9
+ Result = Struct.new(:io, :filename, :content_type, :byte_size, keyword_init: true)
10
+
11
+ attr_reader :url, :settings
12
+
13
+ def initialize(url, settings: nil)
14
+ @url = url
15
+ @settings = settings || SourceMonitor.config.images
16
+ end
17
+
18
+ # Downloads the image and returns a Result, or nil if download fails
19
+ # or the image does not meet validation criteria.
20
+ def call
21
+ response = fetch_image
22
+ return unless response
23
+
24
+ content_type = response.headers["content-type"]&.split(";")&.first&.strip&.downcase
25
+ return unless allowed_content_type?(content_type)
26
+
27
+ body = response.body
28
+ return unless body && body.bytesize > 0
29
+ return if body.bytesize > settings.max_download_size
30
+
31
+ filename = derive_filename(url, content_type)
32
+
33
+ Result.new(
34
+ io: StringIO.new(body),
35
+ filename: filename,
36
+ content_type: content_type,
37
+ byte_size: body.bytesize
38
+ )
39
+ rescue Faraday::Error, URI::InvalidURIError, Timeout::Error
40
+ nil
41
+ end
42
+
43
+ private
44
+
45
+ def fetch_image
46
+ connection = Faraday.new do |f|
47
+ f.options.timeout = settings.download_timeout
48
+ f.options.open_timeout = [ settings.download_timeout / 2, 5 ].min
49
+ f.headers["User-Agent"] = SourceMonitor.config.http.user_agent || "SourceMonitor/#{SourceMonitor::VERSION}"
50
+ f.headers["Accept"] = "image/*"
51
+ f.adapter Faraday.default_adapter
52
+ end
53
+
54
+ response = connection.get(url)
55
+ return response if response.status == 200
56
+
57
+ nil
58
+ end
59
+
60
+ def allowed_content_type?(content_type)
61
+ return false if content_type.blank?
62
+
63
+ settings.allowed_content_types.include?(content_type)
64
+ end
65
+
66
+ def derive_filename(image_url, content_type)
67
+ uri = URI.parse(image_url)
68
+ basename = File.basename(uri.path) if uri.path.present?
69
+
70
+ if basename.present? && basename.include?(".")
71
+ basename
72
+ else
73
+ ext = Rack::Mime::MIME_TYPES.invert[content_type] || ".bin"
74
+ "image-#{SecureRandom.hex(8)}#{ext}"
75
+ end
76
+ rescue URI::InvalidURIError
77
+ ext = Rack::Mime::MIME_TYPES.invert[content_type] || ".bin"
78
+ "image-#{SecureRandom.hex(8)}#{ext}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -61,6 +61,22 @@ module SourceMonitor
61
61
  end
62
62
  end
63
63
 
64
+ def url_label
65
+ if fetch?
66
+ domain_from_feed_url
67
+ elsif scrape?
68
+ entry.item&.url
69
+ end
70
+ end
71
+
72
+ def url_href
73
+ if fetch?
74
+ entry.source&.feed_url
75
+ elsif scrape?
76
+ entry.item&.url
77
+ end
78
+ end
79
+
64
80
  def source_label
65
81
  entry.source&.name
66
82
  end
@@ -142,6 +158,15 @@ module SourceMonitor
142
158
  private
143
159
 
144
160
  attr_reader :entry, :url_helpers
161
+
162
+ def domain_from_feed_url
163
+ feed_url = entry.source&.feed_url
164
+ return nil if feed_url.blank?
165
+
166
+ URI.parse(feed_url.to_s).host
167
+ rescue URI::InvalidURIError
168
+ nil
169
+ end
145
170
  end
146
171
 
147
172
  def initialize(entries:, url_helpers:)
@@ -23,6 +23,13 @@ module SourceMonitor
23
23
  handle_summary(summary)
24
24
  end
25
25
 
26
+ desc "upgrade", "Upgrade SourceMonitor after a gem version change"
27
+ def upgrade
28
+ command = UpgradeCommand.new
29
+ summary = command.call
30
+ handle_summary(summary)
31
+ end
32
+
26
33
  private
27
34
 
28
35
  def handle_summary(summary)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SourceMonitor
6
+ module Setup
7
+ class ProcfilePatcher
8
+ JOBS_ENTRY = "jobs: bundle exec rake solid_queue:start"
9
+
10
+ def initialize(path: "Procfile.dev")
11
+ @path = Pathname.new(path)
12
+ end
13
+
14
+ def patch
15
+ if path.exist?
16
+ content = path.read
17
+ return false if content.match?(/^jobs:/)
18
+
19
+ path.open("a") { |f| f.puts("", JOBS_ENTRY) }
20
+ else
21
+ path.write("web: bin/rails server -p 3000\n#{JOBS_ENTRY}\n")
22
+ end
23
+ true
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :path
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ module SourceMonitor
7
+ module Setup
8
+ class QueueConfigPatcher
9
+ RECURRING_SCHEDULE_VALUE = "config/recurring.yml"
10
+
11
+ DEFAULT_DISPATCHER = {
12
+ "polling_interval" => 1,
13
+ "batch_size" => 500,
14
+ "recurring_schedule" => RECURRING_SCHEDULE_VALUE
15
+ }.freeze
16
+
17
+ def initialize(path: "config/queue.yml")
18
+ @path = Pathname.new(path)
19
+ end
20
+
21
+ def patch
22
+ return false unless path.exist?
23
+
24
+ parsed = YAML.safe_load(path.read, aliases: true) || {}
25
+ return false if has_recurring_schedule?(parsed)
26
+
27
+ add_recurring_schedule!(parsed)
28
+ path.write(YAML.dump(parsed))
29
+ true
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :path
35
+
36
+ def has_recurring_schedule?(parsed)
37
+ parsed.each_value do |value|
38
+ next unless value.is_a?(Hash)
39
+
40
+ dispatchers = value["dispatchers"]
41
+ if dispatchers.is_a?(Array)
42
+ return true if dispatchers.any? { |d| d.is_a?(Hash) && d.key?("recurring_schedule") }
43
+ end
44
+
45
+ return true if has_recurring_schedule?(value)
46
+ end
47
+
48
+ if parsed.key?("dispatchers") && parsed["dispatchers"].is_a?(Array)
49
+ return true if parsed["dispatchers"].any? { |d| d.is_a?(Hash) && d.key?("recurring_schedule") }
50
+ end
51
+
52
+ false
53
+ end
54
+
55
+ def add_recurring_schedule!(parsed)
56
+ found_dispatchers = false
57
+
58
+ parsed.each_value do |value|
59
+ next unless value.is_a?(Hash)
60
+
61
+ if value.key?("dispatchers") && value["dispatchers"].is_a?(Array)
62
+ value["dispatchers"].each do |dispatcher|
63
+ next unless dispatcher.is_a?(Hash)
64
+ dispatcher["recurring_schedule"] ||= RECURRING_SCHEDULE_VALUE
65
+ end
66
+ found_dispatchers = true
67
+ end
68
+ end
69
+
70
+ if parsed.key?("dispatchers") && parsed["dispatchers"].is_a?(Array)
71
+ parsed["dispatchers"].each do |dispatcher|
72
+ next unless dispatcher.is_a?(Hash)
73
+ dispatcher["recurring_schedule"] ||= RECURRING_SCHEDULE_VALUE
74
+ end
75
+ found_dispatchers = true
76
+ end
77
+
78
+ unless found_dispatchers
79
+ parsed["dispatchers"] = [ DEFAULT_DISPATCHER.dup ]
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -11,6 +11,7 @@ module SourceMonitor
11
11
  CONSUMER_SKILLS = %w[
12
12
  sm-host-setup sm-configure sm-scraper-adapter
13
13
  sm-event-handler sm-model-extension sm-dashboard-widget
14
+ sm-upgrade
14
15
  ].freeze
15
16
 
16
17
  CONTRIBUTOR_SKILLS = %w[
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Setup
5
+ class UpgradeCommand
6
+ def initialize(
7
+ migration_installer: MigrationInstaller.new,
8
+ install_generator: InstallGenerator.new,
9
+ verifier: Verification::Runner.new,
10
+ version_file: File.join(Dir.pwd, ".source_monitor_version"),
11
+ current_version: SourceMonitor::VERSION
12
+ )
13
+ @migration_installer = migration_installer
14
+ @install_generator = install_generator
15
+ @verifier = verifier
16
+ @version_file = version_file
17
+ @current_version = current_version
18
+ end
19
+
20
+ def call
21
+ stored = read_stored_version
22
+
23
+ if stored == current_version
24
+ return up_to_date_summary
25
+ end
26
+
27
+ migration_installer.install
28
+ install_generator.run
29
+ summary = verifier.call
30
+ write_version_marker
31
+ summary
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :migration_installer, :install_generator, :verifier, :version_file, :current_version
37
+
38
+ def read_stored_version
39
+ return nil unless File.exist?(version_file)
40
+
41
+ File.read(version_file).strip
42
+ end
43
+
44
+ def write_version_marker
45
+ File.write(version_file, current_version)
46
+ end
47
+
48
+ def up_to_date_summary
49
+ result = Verification::Result.new(
50
+ key: :upgrade,
51
+ name: "Upgrade",
52
+ status: :ok,
53
+ details: "Already up to date (v#{current_version})"
54
+ )
55
+ Verification::Summary.new([ result ])
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Setup
5
+ module Verification
6
+ class PendingMigrationsVerifier
7
+ MIGRATION_TIMESTAMP_PATTERN = /\A\d+_/
8
+
9
+ def initialize(
10
+ engine_migrations_path: default_engine_migrations_path,
11
+ host_migrations_path: default_host_migrations_path,
12
+ connection: default_connection
13
+ )
14
+ @engine_migrations_path = engine_migrations_path
15
+ @host_migrations_path = host_migrations_path
16
+ @connection = connection
17
+ end
18
+
19
+ def call
20
+ engine_names = source_monitor_migration_names(engine_migrations_path)
21
+ return ok_result("No SourceMonitor engine migrations found") if engine_names.empty?
22
+
23
+ host_names = migration_names(host_migrations_path)
24
+ missing = engine_names - host_names
25
+
26
+ if missing.any?
27
+ warning_result(
28
+ "#{missing.size} SourceMonitor migration(s) not copied to host: #{missing.join(', ')}",
29
+ "Run `bin/source_monitor upgrade` or `bin/rails railties:install:migrations FROM=source_monitor`"
30
+ )
31
+ elsif connection.migration_context.needs_migration?
32
+ warning_result(
33
+ "All SourceMonitor migrations are copied but some migrations are pending",
34
+ "Run `bin/rails db:migrate` to apply pending migrations"
35
+ )
36
+ else
37
+ ok_result("All SourceMonitor migrations are present and up to date")
38
+ end
39
+ rescue StandardError => e
40
+ error_result(
41
+ "Migration verification failed: #{e.message}",
42
+ "Check database connectivity and migration file permissions"
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :engine_migrations_path, :host_migrations_path, :connection
49
+
50
+ def default_engine_migrations_path
51
+ SourceMonitor::Engine.root.join("db/migrate")
52
+ end
53
+
54
+ def default_host_migrations_path
55
+ Rails.root.join("db/migrate")
56
+ end
57
+
58
+ def default_connection
59
+ ActiveRecord::Base.connection
60
+ end
61
+
62
+ def source_monitor_migration_names(path)
63
+ migration_names(path).select { |name| name.include?("source_monitor") }
64
+ end
65
+
66
+ def migration_names(path)
67
+ return [] unless File.directory?(path.to_s)
68
+
69
+ Dir.children(path.to_s)
70
+ .select { |f| f.end_with?(".rb") }
71
+ .map { |f| strip_timestamp(f) }
72
+ end
73
+
74
+ def strip_timestamp(filename)
75
+ filename.sub(MIGRATION_TIMESTAMP_PATTERN, "").delete_suffix(".rb")
76
+ end
77
+
78
+ def ok_result(details)
79
+ Result.new(key: :pending_migrations, name: "Pending Migrations", status: :ok, details: details)
80
+ end
81
+
82
+ def warning_result(details, remediation)
83
+ Result.new(key: :pending_migrations, name: "Pending Migrations", status: :warning, details: details, remediation: remediation)
84
+ end
85
+
86
+ def error_result(details, remediation)
87
+ Result.new(key: :pending_migrations, name: "Pending Migrations", status: :error, details: details, remediation: remediation)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Setup
5
+ module Verification
6
+ class RecurringScheduleVerifier
7
+ SOURCE_MONITOR_KEY_PREFIX = "source_monitor_"
8
+ SOURCE_MONITOR_NAMESPACE = "SourceMonitor::"
9
+
10
+ def initialize(task_relation: default_task_relation, connection: default_connection)
11
+ @task_relation = task_relation
12
+ @connection = connection
13
+ end
14
+
15
+ def call
16
+ return missing_gem_result unless task_relation
17
+ return missing_tables_result unless tables_present?
18
+
19
+ tasks = all_tasks
20
+ sm_tasks = source_monitor_tasks(tasks)
21
+
22
+ if sm_tasks.any?
23
+ ok_result("#{sm_tasks.size} SourceMonitor recurring task(s) registered")
24
+ elsif tasks.any?
25
+ warning_result(
26
+ "Recurring tasks exist but none belong to SourceMonitor",
27
+ "Add SourceMonitor entries to config/recurring.yml and ensure the dispatcher has `recurring_schedule: config/recurring.yml`"
28
+ )
29
+ else
30
+ warning_result(
31
+ "No recurring tasks are registered with Solid Queue",
32
+ "Configure a dispatcher with `recurring_schedule: config/recurring.yml` in config/queue.yml and ensure recurring.yml contains SourceMonitor task entries"
33
+ )
34
+ end
35
+ rescue StandardError => e
36
+ error_result(
37
+ "Recurring schedule verification failed: #{e.message}",
38
+ "Verify Solid Queue migrations are up to date and the dispatcher is configured with recurring_schedule"
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :task_relation, :connection
45
+
46
+ def default_task_relation
47
+ SolidQueue::RecurringTask if defined?(SolidQueue::RecurringTask)
48
+ end
49
+
50
+ def default_connection
51
+ SolidQueue::RecurringTask.connection if defined?(SolidQueue::RecurringTask)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ def tables_present?
57
+ return false unless connection
58
+
59
+ connection.table_exists?(task_relation.table_name)
60
+ end
61
+
62
+ def all_tasks
63
+ task_relation.all.to_a
64
+ end
65
+
66
+ def source_monitor_tasks(tasks)
67
+ tasks.select do |task|
68
+ task.key.start_with?(SOURCE_MONITOR_KEY_PREFIX) ||
69
+ task.class_name.to_s.start_with?(SOURCE_MONITOR_NAMESPACE) ||
70
+ task.command.to_s.include?(SOURCE_MONITOR_NAMESPACE)
71
+ end
72
+ end
73
+
74
+ def missing_gem_result
75
+ error_result(
76
+ "Solid Queue gem is not available",
77
+ "Add `solid_queue` to your Gemfile and bundle install"
78
+ )
79
+ end
80
+
81
+ def missing_tables_result
82
+ error_result(
83
+ "Solid Queue recurring tasks table is missing",
84
+ "Run `rails solid_queue:install` or copy the engine's Solid Queue migration"
85
+ )
86
+ end
87
+
88
+ def ok_result(details)
89
+ Result.new(key: :recurring_schedule, name: "Recurring Schedule", status: :ok, details: details)
90
+ end
91
+
92
+ def warning_result(details, remediation)
93
+ Result.new(key: :recurring_schedule, name: "Recurring Schedule", status: :warning, details: details, remediation: remediation)
94
+ end
95
+
96
+ def error_result(details, remediation)
97
+ Result.new(key: :recurring_schedule, name: "Recurring Schedule", status: :error, details: details, remediation: remediation)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -18,7 +18,7 @@ module SourceMonitor
18
18
  attr_reader :verifiers
19
19
 
20
20
  def default_verifiers
21
- [ SolidQueueVerifier.new, ActionCableVerifier.new ]
21
+ [ PendingMigrationsVerifier.new, SolidQueueVerifier.new, RecurringScheduleVerifier.new, ActionCableVerifier.new ]
22
22
  end
23
23
  end
24
24
  end
@@ -21,7 +21,7 @@ module SourceMonitor
21
21
  if recent
22
22
  ok_result("Solid Queue workers are reporting heartbeats")
23
23
  else
24
- warning_result("No Solid Queue workers have reported in the last #{DEFAULT_HEARTBEAT_THRESHOLD.inspect}", "Start a Solid Queue worker with `bin/rails solid_queue:start` and ensure it stays running")
24
+ warning_result("No Solid Queue workers have reported in the last #{DEFAULT_HEARTBEAT_THRESHOLD.inspect}", "Start a Solid Queue worker with `bin/rails solid_queue:start` or add `jobs: bundle exec rake solid_queue:start` to Procfile.dev and run `bin/dev`")
25
25
  end
26
26
  rescue StandardError => e
27
27
  error_result("Solid Queue verification failed: #{e.message}", "Verify Solid Queue migrations are up to date and workers can access the database")
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require_relative "procfile_patcher"
5
+ require_relative "queue_config_patcher"
4
6
  require_relative "skills_installer"
5
7
 
6
8
  module SourceMonitor
@@ -35,6 +37,8 @@ module SourceMonitor
35
37
  install_generator: InstallGenerator.new,
36
38
  migration_installer: MigrationInstaller.new,
37
39
  initializer_patcher: InitializerPatcher.new,
40
+ procfile_patcher: ProcfilePatcher.new,
41
+ queue_config_patcher: QueueConfigPatcher.new,
38
42
  devise_detector: method(:default_devise_detector),
39
43
  verifier: Verification::Runner.new,
40
44
  skills_installer: SkillsInstaller.new
@@ -47,6 +51,8 @@ module SourceMonitor
47
51
  @install_generator = install_generator
48
52
  @migration_installer = migration_installer
49
53
  @initializer_patcher = initializer_patcher
54
+ @procfile_patcher = procfile_patcher
55
+ @queue_config_patcher = queue_config_patcher
50
56
  @devise_detector = devise_detector
51
57
  @verifier = verifier
52
58
  @skills_installer = skills_installer
@@ -64,6 +70,8 @@ module SourceMonitor
64
70
  install_generator.run(mount_path: mount_path)
65
71
  migration_installer.install
66
72
  initializer_patcher.ensure_navigation_hint(mount_path: mount_path)
73
+ procfile_patcher.patch
74
+ queue_config_patcher.patch
67
75
 
68
76
  if devise_available? && prompter.yes?("Wire Devise authentication hooks into SourceMonitor?", default: true)
69
77
  initializer_patcher.ensure_devise_hooks
@@ -89,6 +97,8 @@ module SourceMonitor
89
97
  :install_generator,
90
98
  :migration_installer,
91
99
  :initializer_patcher,
100
+ :procfile_patcher,
101
+ :queue_config_patcher,
92
102
  :devise_detector,
93
103
  :verifier,
94
104
  :skills_installer
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SourceMonitor
4
- VERSION = "0.3.3"
4
+ VERSION = "0.5.0"
5
5
  end