source_monitor 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.rubocop.yml +12 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +132 -0
- data/CHANGELOG.md +66 -0
- data/CONTRIBUTING.md +31 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +411 -0
- data/MIT-LICENSE +20 -0
- data/README.md +108 -0
- data/Rakefile +8 -0
- data/app/assets/builds/.keep +0 -0
- data/app/assets/config/source_monitor_manifest.js +4 -0
- data/app/assets/images/source_monitor/.keep +0 -0
- data/app/assets/javascripts/source_monitor/application.js +20 -0
- data/app/assets/javascripts/source_monitor/controllers/async_submit_controller.js +36 -0
- data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +109 -0
- data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +53 -0
- data/app/assets/javascripts/source_monitor/turbo_actions.js +13 -0
- data/app/assets/stylesheets/source_monitor/application.tailwind.css +13 -0
- data/app/assets/svgs/source_monitor/.keep +0 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +81 -0
- data/app/controllers/source_monitor/application_controller.rb +62 -0
- data/app/controllers/source_monitor/dashboard_controller.rb +27 -0
- data/app/controllers/source_monitor/fetch_logs_controller.rb +9 -0
- data/app/controllers/source_monitor/health_controller.rb +10 -0
- data/app/controllers/source_monitor/items_controller.rb +116 -0
- data/app/controllers/source_monitor/logs_controller.rb +15 -0
- data/app/controllers/source_monitor/scrape_logs_controller.rb +9 -0
- data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +35 -0
- data/app/controllers/source_monitor/source_fetches_controller.rb +22 -0
- data/app/controllers/source_monitor/source_health_checks_controller.rb +34 -0
- data/app/controllers/source_monitor/source_health_resets_controller.rb +27 -0
- data/app/controllers/source_monitor/source_retries_controller.rb +22 -0
- data/app/controllers/source_monitor/source_turbo_responses.rb +115 -0
- data/app/controllers/source_monitor/sources_controller.rb +179 -0
- data/app/helpers/source_monitor/application_helper.rb +327 -0
- data/app/jobs/source_monitor/application_job.rb +13 -0
- data/app/jobs/source_monitor/fetch_feed_job.rb +117 -0
- data/app/jobs/source_monitor/item_cleanup_job.rb +48 -0
- data/app/jobs/source_monitor/log_cleanup_job.rb +47 -0
- data/app/jobs/source_monitor/schedule_fetches_job.rb +29 -0
- data/app/jobs/source_monitor/scrape_item_job.rb +47 -0
- data/app/jobs/source_monitor/source_health_check_job.rb +77 -0
- data/app/mailers/source_monitor/application_mailer.rb +17 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/source_monitor/loggable.rb +18 -0
- data/app/models/source_monitor/application_record.rb +5 -0
- data/app/models/source_monitor/fetch_log.rb +31 -0
- data/app/models/source_monitor/health_check_log.rb +28 -0
- data/app/models/source_monitor/item.rb +102 -0
- data/app/models/source_monitor/item_content.rb +11 -0
- data/app/models/source_monitor/log_entry.rb +56 -0
- data/app/models/source_monitor/scrape_log.rb +31 -0
- data/app/models/source_monitor/source.rb +115 -0
- data/app/views/layouts/source_monitor/application.html.erb +54 -0
- data/app/views/source_monitor/dashboard/_fetch_schedule.html.erb +90 -0
- data/app/views/source_monitor/dashboard/_job_metrics.html.erb +82 -0
- data/app/views/source_monitor/dashboard/_recent_activity.html.erb +39 -0
- data/app/views/source_monitor/dashboard/_stat_card.html.erb +6 -0
- data/app/views/source_monitor/dashboard/_stats.html.erb +9 -0
- data/app/views/source_monitor/dashboard/index.html.erb +48 -0
- data/app/views/source_monitor/fetch_logs/show.html.erb +90 -0
- data/app/views/source_monitor/items/_details.html.erb +234 -0
- data/app/views/source_monitor/items/_details_wrapper.html.erb +3 -0
- data/app/views/source_monitor/items/index.html.erb +147 -0
- data/app/views/source_monitor/items/show.html.erb +3 -0
- data/app/views/source_monitor/logs/index.html.erb +208 -0
- data/app/views/source_monitor/scrape_logs/show.html.erb +73 -0
- data/app/views/source_monitor/shared/_toast.html.erb +34 -0
- data/app/views/source_monitor/sources/_bulk_scrape_form.html.erb +64 -0
- data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +53 -0
- data/app/views/source_monitor/sources/_details.html.erb +302 -0
- data/app/views/source_monitor/sources/_details_wrapper.html.erb +3 -0
- data/app/views/source_monitor/sources/_empty_state_row.html.erb +5 -0
- data/app/views/source_monitor/sources/_fetch_interval_heatmap.html.erb +46 -0
- data/app/views/source_monitor/sources/_form.html.erb +143 -0
- data/app/views/source_monitor/sources/_health_status_badge.html.erb +46 -0
- data/app/views/source_monitor/sources/_row.html.erb +102 -0
- data/app/views/source_monitor/sources/edit.html.erb +28 -0
- data/app/views/source_monitor/sources/index.html.erb +153 -0
- data/app/views/source_monitor/sources/new.html.erb +22 -0
- data/app/views/source_monitor/sources/show.html.erb +3 -0
- data/config/coverage_baseline.json +2010 -0
- data/config/initializers/feedjira.rb +19 -0
- data/config/routes.rb +18 -0
- data/config/tailwind.config.js +17 -0
- data/db/migrate/20241008120000_create_source_monitor_sources.rb +40 -0
- data/db/migrate/20241008121000_create_source_monitor_items.rb +44 -0
- data/db/migrate/20241008122000_create_source_monitor_fetch_logs.rb +32 -0
- data/db/migrate/20241008123000_create_source_monitor_scrape_logs.rb +25 -0
- data/db/migrate/20251008183000_change_fetch_interval_to_minutes.rb +23 -0
- data/db/migrate/20251009090000_create_source_monitor_item_contents.rb +38 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +5 -0
- data/db/migrate/20251010090000_add_adaptive_fetching_toggle_to_sources.rb +7 -0
- data/db/migrate/20251010123000_add_deleted_at_to_source_monitor_items.rb +8 -0
- data/db/migrate/20251010153000_add_type_to_source_monitor_sources.rb +8 -0
- data/db/migrate/20251010154500_add_fetch_status_to_source_monitor_sources.rb +9 -0
- data/db/migrate/20251010160000_create_solid_cable_messages.rb +16 -0
- data/db/migrate/20251011090000_add_fetch_retry_state_to_sources.rb +14 -0
- data/db/migrate/20251012090000_add_health_fields_to_sources.rb +17 -0
- data/db/migrate/20251012100000_optimize_source_monitor_database_performance.rb +13 -0
- data/db/migrate/20251014064947_add_not_null_constraints_to_items.rb +30 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +29 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +18 -0
- data/db/migrate/20251015100000_create_source_monitor_log_entries.rb +89 -0
- data/db/migrate/20251022100000_create_source_monitor_health_check_logs.rb +22 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +29 -0
- data/docs/configuration.md +170 -0
- data/docs/deployment.md +63 -0
- data/docs/gh-cli-workflow.md +44 -0
- data/docs/installation.md +144 -0
- data/docs/troubleshooting.md +76 -0
- data/eslint.config.mjs +27 -0
- data/lib/generators/source_monitor/install/install_generator.rb +59 -0
- data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +155 -0
- data/lib/source_monitor/analytics/source_activity_rates.rb +53 -0
- data/lib/source_monitor/analytics/source_fetch_interval_distribution.rb +57 -0
- data/lib/source_monitor/analytics/sources_index_metrics.rb +92 -0
- data/lib/source_monitor/assets/bundler.rb +49 -0
- data/lib/source_monitor/assets.rb +6 -0
- data/lib/source_monitor/configuration.rb +654 -0
- data/lib/source_monitor/dashboard/queries.rb +356 -0
- data/lib/source_monitor/dashboard/quick_action.rb +7 -0
- data/lib/source_monitor/dashboard/quick_actions_presenter.rb +26 -0
- data/lib/source_monitor/dashboard/recent_activity.rb +30 -0
- data/lib/source_monitor/dashboard/recent_activity_presenter.rb +77 -0
- data/lib/source_monitor/dashboard/turbo_broadcaster.rb +87 -0
- data/lib/source_monitor/dashboard/upcoming_fetch_schedule.rb +126 -0
- data/lib/source_monitor/engine.rb +107 -0
- data/lib/source_monitor/events.rb +110 -0
- data/lib/source_monitor/feedjira_extensions.rb +103 -0
- data/lib/source_monitor/fetching/advisory_lock.rb +54 -0
- data/lib/source_monitor/fetching/completion/event_publisher.rb +22 -0
- data/lib/source_monitor/fetching/completion/follow_up_handler.rb +37 -0
- data/lib/source_monitor/fetching/completion/retention_handler.rb +30 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +627 -0
- data/lib/source_monitor/fetching/fetch_error.rb +88 -0
- data/lib/source_monitor/fetching/fetch_runner.rb +142 -0
- data/lib/source_monitor/fetching/retry_policy.rb +85 -0
- data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +146 -0
- data/lib/source_monitor/health/source_health_check.rb +100 -0
- data/lib/source_monitor/health/source_health_monitor.rb +210 -0
- data/lib/source_monitor/health/source_health_reset.rb +68 -0
- data/lib/source_monitor/health.rb +46 -0
- data/lib/source_monitor/http.rb +85 -0
- data/lib/source_monitor/instrumentation.rb +52 -0
- data/lib/source_monitor/items/item_creator.rb +601 -0
- data/lib/source_monitor/items/retention_pruner.rb +146 -0
- data/lib/source_monitor/items/retention_strategies/destroy.rb +26 -0
- data/lib/source_monitor/items/retention_strategies/soft_delete.rb +50 -0
- data/lib/source_monitor/items/retention_strategies.rb +9 -0
- data/lib/source_monitor/jobs/cleanup_options.rb +85 -0
- data/lib/source_monitor/jobs/fetch_failure_subscriber.rb +129 -0
- data/lib/source_monitor/jobs/solid_queue_metrics.rb +199 -0
- data/lib/source_monitor/jobs/visibility.rb +133 -0
- data/lib/source_monitor/logs/entry_sync.rb +69 -0
- data/lib/source_monitor/logs/filter_set.rb +163 -0
- data/lib/source_monitor/logs/query.rb +81 -0
- data/lib/source_monitor/logs/table_presenter.rb +161 -0
- data/lib/source_monitor/metrics.rb +77 -0
- data/lib/source_monitor/model_extensions.rb +109 -0
- data/lib/source_monitor/models/sanitizable.rb +76 -0
- data/lib/source_monitor/models/url_normalizable.rb +84 -0
- data/lib/source_monitor/pagination/paginator.rb +90 -0
- data/lib/source_monitor/realtime/adapter.rb +97 -0
- data/lib/source_monitor/realtime/broadcaster.rb +237 -0
- data/lib/source_monitor/realtime.rb +17 -0
- data/lib/source_monitor/release/changelog.rb +59 -0
- data/lib/source_monitor/release/runner.rb +73 -0
- data/lib/source_monitor/scheduler.rb +82 -0
- data/lib/source_monitor/scrapers/base.rb +105 -0
- data/lib/source_monitor/scrapers/fetchers/http_fetcher.rb +97 -0
- data/lib/source_monitor/scrapers/parsers/readability_parser.rb +101 -0
- data/lib/source_monitor/scrapers/readability.rb +156 -0
- data/lib/source_monitor/scraping/bulk_result_presenter.rb +85 -0
- data/lib/source_monitor/scraping/bulk_source_scraper.rb +233 -0
- data/lib/source_monitor/scraping/enqueuer.rb +125 -0
- data/lib/source_monitor/scraping/item_scraper/adapter_resolver.rb +44 -0
- data/lib/source_monitor/scraping/item_scraper/persistence.rb +189 -0
- data/lib/source_monitor/scraping/item_scraper.rb +84 -0
- data/lib/source_monitor/scraping/scheduler.rb +43 -0
- data/lib/source_monitor/scraping/state.rb +79 -0
- data/lib/source_monitor/security/authentication.rb +85 -0
- data/lib/source_monitor/security/parameter_sanitizer.rb +42 -0
- data/lib/source_monitor/sources/turbo_stream_presenter.rb +54 -0
- data/lib/source_monitor/turbo_streams/stream_responder.rb +95 -0
- data/lib/source_monitor/version.rb +3 -0
- data/lib/source_monitor.rb +149 -0
- data/lib/tasks/recover_stalled_fetches.rake +16 -0
- data/lib/tasks/source_monitor_assets.rake +28 -0
- data/lib/tasks/source_monitor_tasks.rake +29 -0
- data/lib/tasks/test_smoke.rake +12 -0
- data/package-lock.json +3997 -0
- data/package.json +29 -0
- data/postcss.config.js +6 -0
- data/source_monitor.gemspec +46 -0
- data/stylelint.config.js +12 -0
- metadata +469 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<%= form_with model: source, class: "space-y-6" do |form| %>
|
|
2
|
+
<div class="space-y-4 rounded-lg border border-slate-200 bg-white px-5 py-5 shadow-sm">
|
|
3
|
+
<div>
|
|
4
|
+
<%= form.label :name, class: "block text-sm font-medium text-slate-800" %>
|
|
5
|
+
<%= form.text_field :name, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div>
|
|
9
|
+
<%= form.label :feed_url, class: "block text-sm font-medium text-slate-800" %>
|
|
10
|
+
<%= form.url_field :feed_url, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div>
|
|
14
|
+
<%= form.label :website_url, class: "block text-sm font-medium text-slate-800" %>
|
|
15
|
+
<%= form.url_field :website_url, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<label class="inline-flex items-center space-x-2">
|
|
19
|
+
<%= form.check_box :active %>
|
|
20
|
+
<span>Active</span>
|
|
21
|
+
</label>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="space-y-4 rounded-lg border border-slate-200 bg-white px-5 py-5 shadow-sm">
|
|
25
|
+
<div>
|
|
26
|
+
<%= form.label :fetch_interval_minutes, "Fetch interval (minutes)", class: "block text-sm font-medium text-slate-800" %>
|
|
27
|
+
<%= form.number_field :fetch_interval_minutes, min: 1, step: 1, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
28
|
+
<p class="mt-1 text-xs text-slate-500">Minimum 5 minutes recommended.</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<label class="flex items-start space-x-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
|
|
32
|
+
<%= form.check_box :adaptive_fetching_enabled, class: "mt-1" %>
|
|
33
|
+
<span>
|
|
34
|
+
<span class="block text-sm font-medium text-slate-800">Automatically adjust fetch interval</span>
|
|
35
|
+
<span class="mt-1 block text-xs text-slate-500">
|
|
36
|
+
When enabled, SourceMonitor lengthens or shortens the interval based on feed activity and failures. Disable to keep the interval fixed at the value above.
|
|
37
|
+
</span>
|
|
38
|
+
</span>
|
|
39
|
+
</label>
|
|
40
|
+
|
|
41
|
+
<div>
|
|
42
|
+
<%= form.label :health_auto_pause_threshold, "Auto-pause threshold", class: "block text-sm font-medium text-slate-800" %>
|
|
43
|
+
<%= form.number_field :health_auto_pause_threshold, min: 0, max: 1, step: 0.05, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
44
|
+
<p class="mt-1 text-xs text-slate-500">
|
|
45
|
+
Optional override for this source. When the rolling success rate falls below this fraction (defaults to <%= SourceMonitor.config.health.auto_pause_threshold %>), the source is temporarily auto-paused.
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
51
|
+
<div class="border-b border-slate-200 px-5 py-4">
|
|
52
|
+
<h2 class="text-lg font-medium">Retention</h2>
|
|
53
|
+
<p class="mt-1 text-xs text-slate-500">
|
|
54
|
+
Choose how long SourceMonitor keeps items for this source. Pruning runs after every fetch so the UI always reflects active retention rules.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="grid gap-4 px-5 py-5 sm:grid-cols-2">
|
|
58
|
+
<div>
|
|
59
|
+
<%= form.label :items_retention_days, "Retention window (days)", class: "block text-sm font-medium text-slate-800" %>
|
|
60
|
+
<%= form.number_field :items_retention_days, min: 0, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
61
|
+
<p class="mt-1 text-xs text-slate-500">
|
|
62
|
+
Leave blank to keep historical items indefinitely. Items older than the configured window are removed automatically.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div>
|
|
67
|
+
<%= form.label :max_items, "Maximum stored items", class: "block text-sm font-medium text-slate-800" %>
|
|
68
|
+
<%= form.number_field :max_items, min: 0, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
69
|
+
<p class="mt-1 text-xs text-slate-500">
|
|
70
|
+
Optional hard cap. SourceMonitor keeps the newest items and prunes the rest after each fetch.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
77
|
+
<div class="border-b border-slate-200 px-5 py-4">
|
|
78
|
+
<h2 class="text-lg font-medium">Feed Content Processing</h2>
|
|
79
|
+
<p class="mt-1 text-xs text-slate-500">Control whether feed-supplied content is cleaned with Readability before storing items.</p>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="space-y-4 px-5 py-5">
|
|
82
|
+
<label class="inline-flex items-center space-x-2">
|
|
83
|
+
<%= form.check_box :feed_content_readability_enabled %>
|
|
84
|
+
<span>Process feed content with Readability</span>
|
|
85
|
+
</label>
|
|
86
|
+
<p class="text-xs text-slate-500">
|
|
87
|
+
When enabled, the raw feed HTML is passed through Readability. Scraping configuration below remains independent, so you can mix-and-match feed content and scraping strategies.
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<% scrape_settings = (source.scrape_settings || {}).with_indifferent_access %>
|
|
93
|
+
<% selectors = scrape_settings[:selectors] || {} %>
|
|
94
|
+
|
|
95
|
+
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
96
|
+
<div class="border-b border-slate-200 px-5 py-4">
|
|
97
|
+
<h2 class="text-lg font-medium">Scraping Configuration</h2>
|
|
98
|
+
<p class="mt-1 text-xs text-slate-500">Control how content extraction runs for this source.</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="space-y-5 px-5 py-5">
|
|
101
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
102
|
+
<label class="inline-flex items-center space-x-2">
|
|
103
|
+
<%= form.check_box :scraping_enabled %>
|
|
104
|
+
<span>Scraping enabled</span>
|
|
105
|
+
</label>
|
|
106
|
+
|
|
107
|
+
<label class="inline-flex items-center space-x-2">
|
|
108
|
+
<%= form.check_box :auto_scrape %>
|
|
109
|
+
<span>Auto scrape after fetch</span>
|
|
110
|
+
</label>
|
|
111
|
+
|
|
112
|
+
<label class="inline-flex items-center space-x-2">
|
|
113
|
+
<%= form.check_box :requires_javascript %>
|
|
114
|
+
<span>Requires JavaScript</span>
|
|
115
|
+
</label>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div>
|
|
119
|
+
<%= form.label :scraper_adapter, "Scraper adapter", class: "block text-sm font-medium text-slate-800" %>
|
|
120
|
+
<%= form.select :scraper_adapter, [["Readability", "readability"]], {}, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
|
|
121
|
+
<p class="mt-1 text-xs text-slate-500">Additional adapters can be configured in future phases.</p>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
125
|
+
<div>
|
|
126
|
+
<label for="source_scrape_settings_selectors_content" class="block text-sm font-medium text-slate-800">Content CSS selector</label>
|
|
127
|
+
<%= text_field_tag "source[scrape_settings][selectors][content]", selectors[:content], id: "source_scrape_settings_selectors_content", class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2", placeholder: ".article-body" %>
|
|
128
|
+
<p class="mt-1 text-xs text-slate-500">Optional. Overrides Readability extraction target.</p>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div>
|
|
132
|
+
<label for="source_scrape_settings_selectors_title" class="block text-sm font-medium text-slate-800">Title CSS selector</label>
|
|
133
|
+
<%= text_field_tag "source[scrape_settings][selectors][title]", selectors[:title], id: "source_scrape_settings_selectors_title", class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2", placeholder: "h1.article-title" %>
|
|
134
|
+
<p class="mt-1 text-xs text-slate-500">Optional. Leave blank to use feed-provided title.</p>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div>
|
|
141
|
+
<%= form.submit (source.persisted? ? "Update Source" : "Create Source"), class: "inline-flex items-center rounded bg-blue-600 px-4 py-2 text-white shadow hover:bg-blue-500" %>
|
|
142
|
+
</div>
|
|
143
|
+
<% end %>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<% source = local_assigns.fetch(:source) %>
|
|
2
|
+
<% override = local_assigns[:health_status_override] %>
|
|
3
|
+
<% badge = source_health_badge(source, override:) %>
|
|
4
|
+
<% actions = source_health_actions(source) %>
|
|
5
|
+
<% interactive = interactive_health_status?(source, override: override) && actions.any? %>
|
|
6
|
+
|
|
7
|
+
<% if interactive %>
|
|
8
|
+
<div data-controller="dropdown" class="relative inline-block text-left" data-testid="source-health-menu">
|
|
9
|
+
<button type="button"
|
|
10
|
+
class="inline-flex w-full items-center justify-center rounded-full px-3 py-1 text-xs font-semibold <%= badge[:classes] %>"
|
|
11
|
+
data-action="dropdown#toggle click@window->dropdown#hide"
|
|
12
|
+
data-testid="source-health-menu-toggle">
|
|
13
|
+
<% if badge[:show_spinner] %>
|
|
14
|
+
<%= loading_spinner_svg(css_class: "mr-1 h-3.5 w-3.5 animate-spin text-blue-500") %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<span><%= badge[:label] %></span>
|
|
17
|
+
<svg class="ml-2 h-3 w-3 text-slate-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
18
|
+
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06z" clip-rule="evenodd" />
|
|
19
|
+
</svg>
|
|
20
|
+
</button>
|
|
21
|
+
<div data-dropdown-target="menu" class="absolute z-20 mt-2 w-80 origin-top-left rounded-md border border-slate-200 bg-white shadow-lg hidden">
|
|
22
|
+
<div class="py-2">
|
|
23
|
+
<% actions.each do |action| %>
|
|
24
|
+
<% button_classes = "w-full px-4 py-3 text-left text-sm hover:bg-slate-50 focus:outline-none" %>
|
|
25
|
+
<%= button_to action[:path],
|
|
26
|
+
method: action[:method],
|
|
27
|
+
form: { class: "w-full" },
|
|
28
|
+
data: (action[:data] || {}).merge(action: "dropdown#hide"),
|
|
29
|
+
class: button_classes do %>
|
|
30
|
+
<div class="flex flex-col text-slate-700">
|
|
31
|
+
<span class="font-semibold"><%= action[:label] %></span>
|
|
32
|
+
<span class="mt-1 text-xs text-slate-500"><%= action[:description] %></span>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<% else %>
|
|
40
|
+
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%= badge[:classes] %>">
|
|
41
|
+
<% if badge[:show_spinner] %>
|
|
42
|
+
<%= loading_spinner_svg(css_class: "mr-1 h-3.5 w-3.5 animate-spin text-blue-500") %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<%= badge[:label] %>
|
|
45
|
+
</span>
|
|
46
|
+
<% end %>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<% rate_map = local_assigns[:item_activity_rates] || {} %>
|
|
2
|
+
<% activity_rate = rate_map.fetch(source.id, 0.0) %>
|
|
3
|
+
<% health_status_override = local_assigns[:health_status_override] %>
|
|
4
|
+
<% health_status = if !source.active?
|
|
5
|
+
{ label: "Paused", classes: "bg-amber-100 text-amber-700", show_spinner: false }
|
|
6
|
+
else
|
|
7
|
+
source_health_badge(source, override: health_status_override)
|
|
8
|
+
end %>
|
|
9
|
+
<% fetch_status = async_status_badge(source.fetch_status) %>
|
|
10
|
+
<% search_params = local_assigns[:search_params] || {} %>
|
|
11
|
+
<% delete_query =
|
|
12
|
+
if search_params.respond_to?(:to_unsafe_h)
|
|
13
|
+
search_params.to_unsafe_h
|
|
14
|
+
elsif search_params.respond_to?(:to_h)
|
|
15
|
+
search_params.to_h
|
|
16
|
+
elsif search_params.is_a?(Hash)
|
|
17
|
+
search_params
|
|
18
|
+
else
|
|
19
|
+
{}
|
|
20
|
+
end %>
|
|
21
|
+
<% delete_query = delete_query.respond_to?(:compact_blank) ? delete_query.compact_blank : delete_query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? } if delete_query.present? %>
|
|
22
|
+
<% delete_path = delete_query.present? ? source_monitor.source_path(source, q: delete_query) : source_monitor.source_path(source) %>
|
|
23
|
+
|
|
24
|
+
<tr id="<%= dom_id(source, :row) %>" class="hover:bg-slate-50">
|
|
25
|
+
<td class="px-6 py-4">
|
|
26
|
+
<div class="font-medium text-slate-900">
|
|
27
|
+
<%= link_to source.name,
|
|
28
|
+
source_monitor.source_path(source),
|
|
29
|
+
class: "text-slate-900 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
|
30
|
+
data: { turbo_frame: "_top" } %>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="text-xs text-slate-500 truncate max-w-xs"><%= source.feed_url %></div>
|
|
33
|
+
</td>
|
|
34
|
+
<td class="px-6 py-4">
|
|
35
|
+
<div class="flex flex-col gap-2 text-xs">
|
|
36
|
+
<% if source.active? %>
|
|
37
|
+
<%= render "source_monitor/sources/health_status_badge",
|
|
38
|
+
source: source,
|
|
39
|
+
health_status_override: health_status_override %>
|
|
40
|
+
<% else %>
|
|
41
|
+
<span class="inline-flex items-center rounded-full px-3 py-1 font-semibold <%= health_status[:classes] %>"><%= health_status[:label] %></span>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% if source.rolling_success_rate.present? %>
|
|
44
|
+
<span class="text-[11px] text-slate-500">Success Rate: <%= number_to_percentage(source.rolling_success_rate * 100, precision: 0) %></span>
|
|
45
|
+
<% end %>
|
|
46
|
+
<span class="inline-flex items-center rounded-full px-3 py-1 font-semibold <%= fetch_status[:classes] %>">
|
|
47
|
+
<%= loading_spinner_svg(css_class: "mr-1 h-3.5 w-3.5 animate-spin text-blue-500") if fetch_status[:show_spinner] %>
|
|
48
|
+
<%= fetch_status[:label] %>
|
|
49
|
+
<% if source.fetch_status == "fetching" && source.last_fetch_started_at.present? %>
|
|
50
|
+
<span class="ml-2 font-normal text-[10px] text-slate-500">(since <%= source.last_fetch_started_at.strftime("%H:%M:%S") %>)</span>
|
|
51
|
+
<% end %>
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
</td>
|
|
55
|
+
<td class="px-6 py-4 text-sm">
|
|
56
|
+
<%= "#{source.fetch_interval_minutes} min" %>
|
|
57
|
+
<span class="text-xs text-slate-500">(~<%= number_with_precision(source.fetch_interval_minutes / 60.0, precision: 2) %> h)</span>
|
|
58
|
+
</td>
|
|
59
|
+
<td class="px-6 py-4 text-sm"><%= source.items_count %></td>
|
|
60
|
+
<td class="px-6 py-4 text-sm">
|
|
61
|
+
<%= number_with_precision(activity_rate, precision: 2) %>
|
|
62
|
+
<span class="text-xs text-slate-500">/ day</span>
|
|
63
|
+
</td>
|
|
64
|
+
<td class="px-6 py-4 text-xs text-slate-500">
|
|
65
|
+
<%= source.last_fetched_at ? source.last_fetched_at.strftime("%b %d, %H:%M") : "Never" %>
|
|
66
|
+
</td>
|
|
67
|
+
<td class="px-6 py-4 text-right text-sm">
|
|
68
|
+
<div data-controller="dropdown" class="relative inline-block text-left">
|
|
69
|
+
<button type="button"
|
|
70
|
+
class="inline-flex items-center rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-slate-600 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
71
|
+
data-action="dropdown#toggle click@window->dropdown#hide"
|
|
72
|
+
aria-label="Source actions">
|
|
73
|
+
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
74
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94a.75.75 0 0 0-1.093-.332l-.822.548a2.25 2.25 0 0 1-2.287.014l-.856-.506a.75.75 0 0 0-1.087.63l.03.988a2.25 2.25 0 0 1-.639 1.668l-.715.715a.75.75 0 0 0 0 1.06l.715.715a2.25 2.25 0 0 1 .639 1.668l-.03.988a.75.75 0 0 0 1.087.63l.856-.506a2.25 2.25 0 0 1 2.287.014l.822.548a.75.75 0 0 0 1.093-.332l.38-.926a2.25 2.25 0 0 1 1.451-1.297l.964-.258a.75.75 0 0 0 .534-.72v-.946a.75.75 0 0 0-.534-.72l-.964-.258a2.25 2.25 0 0 1-1.45-1.297l-.381-.926Z" />
|
|
75
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" />
|
|
76
|
+
</svg>
|
|
77
|
+
</button>
|
|
78
|
+
<div data-dropdown-target="menu" class="absolute right-0 z-10 mt-2 w-36 origin-top-right rounded-md border border-slate-200 bg-white shadow-lg transition hidden">
|
|
79
|
+
<div class="py-1 text-sm text-slate-700">
|
|
80
|
+
<%= link_to "View",
|
|
81
|
+
source_monitor.source_path(source),
|
|
82
|
+
class: "block px-3 py-2 hover:bg-slate-50",
|
|
83
|
+
data: { action: "dropdown#toggle", turbo_frame: "_top" } %>
|
|
84
|
+
<%= link_to "Edit",
|
|
85
|
+
source_monitor.edit_source_path(source),
|
|
86
|
+
class: "block px-3 py-2 hover:bg-slate-50",
|
|
87
|
+
data: { action: "dropdown#toggle", turbo_frame: "_top" } %>
|
|
88
|
+
<div class="my-1 border-t border-slate-100"></div>
|
|
89
|
+
<%= link_to "Delete",
|
|
90
|
+
delete_path,
|
|
91
|
+
class: "block px-3 py-2 text-rose-600 hover:bg-rose-50 hover:text-rose-700",
|
|
92
|
+
title: "Delete source and remove associated items/logs",
|
|
93
|
+
data: {
|
|
94
|
+
action: "dropdown#toggle",
|
|
95
|
+
turbo_method: :delete,
|
|
96
|
+
turbo_confirm: "Delete this source and remove all associated data?"
|
|
97
|
+
} %>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</td>
|
|
102
|
+
</tr>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div class="mx-auto max-w-2xl py-10">
|
|
2
|
+
<h1 class="text-3xl font-semibold">Edit Source</h1>
|
|
3
|
+
|
|
4
|
+
<% if @source.errors.any? %>
|
|
5
|
+
<div class="mt-4 rounded border border-red-300 bg-red-50 p-4">
|
|
6
|
+
<h2 class="font-medium text-red-700">Please fix the following:</h2>
|
|
7
|
+
<ul class="mt-2 list-disc space-y-1 pl-5 text-red-700">
|
|
8
|
+
<% @source.errors.full_messages.each do |message| %>
|
|
9
|
+
<li><%= message %></li>
|
|
10
|
+
<% end %>
|
|
11
|
+
</ul>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<div class="mt-6">
|
|
16
|
+
<%= render "form", source: @source %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="mt-6 flex items-center gap-4">
|
|
20
|
+
<%= link_to "Back", source_monitor.source_path(@source), class: "text-blue-600 hover:underline" %>
|
|
21
|
+
<%= form_with url: source_monitor.source_path(@source), method: :delete, data: { turbo_confirm: "Delete this source and remove all associated data?", turbo_frame: "_top" }, class: "inline" do %>
|
|
22
|
+
<%= hidden_field_tag :redirect_to, source_monitor.sources_path %>
|
|
23
|
+
<button type="submit" class="text-rose-600 hover:text-rose-500">
|
|
24
|
+
Delete
|
|
25
|
+
</button>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<div class="space-y-6">
|
|
2
|
+
<%= turbo_stream_from "source_monitor_sources" %>
|
|
3
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
4
|
+
<div>
|
|
5
|
+
<h1 class="text-3xl font-semibold text-slate-900">Sources</h1>
|
|
6
|
+
<p class="mt-1 text-sm text-slate-500">Manage feed endpoints and scraping settings.</p>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
|
|
9
|
+
<%= search_form_for @q, url: source_monitor.sources_path, method: :get, html: { class: "w-full sm:w-auto", data: { turbo_frame: "source_monitor_sources_table" } } do |form| %>
|
|
10
|
+
<%= form.label @search_field, "Search sources", class: "sr-only" %>
|
|
11
|
+
<div class="flex rounded-md shadow-sm">
|
|
12
|
+
<%= form.search_field @search_field, placeholder: "Search name or URL…", class: "w-full rounded-l-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:min-w-[16rem]" %>
|
|
13
|
+
<%= form.submit "Search", class: "rounded-r-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500" %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
<%= link_to "New Source", source_monitor.new_source_path, class: "inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<%= render "source_monitor/sources/fetch_interval_heatmap",
|
|
21
|
+
fetch_interval_distribution: @fetch_interval_distribution,
|
|
22
|
+
selected_bucket: @selected_fetch_interval_bucket,
|
|
23
|
+
search_params: @search_params %>
|
|
24
|
+
|
|
25
|
+
<div class="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
26
|
+
<%= turbo_frame_tag "source_monitor_sources_table" do %>
|
|
27
|
+
<% if @search_term.present? || @fetch_interval_filter.present? %>
|
|
28
|
+
<div class="rounded-t-lg border-b border-blue-100 bg-blue-50 px-4 py-3 text-xs text-blue-700">
|
|
29
|
+
<% if @search_term.present? %>
|
|
30
|
+
<% clear_search_query = @search_params.dup %>
|
|
31
|
+
<% clear_search_query.delete("name_or_feed_url_or_website_url_cont") %>
|
|
32
|
+
<% clear_search_query = if clear_search_query.respond_to?(:compact_blank)
|
|
33
|
+
clear_search_query.compact_blank
|
|
34
|
+
else
|
|
35
|
+
clear_search_query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
|
|
36
|
+
end %>
|
|
37
|
+
<% clear_search_path = clear_search_query.empty? ? source_monitor.sources_path : source_monitor.sources_path(q: clear_search_query) %>
|
|
38
|
+
<div>
|
|
39
|
+
Showing results for "<%= @search_term %>".
|
|
40
|
+
<%= link_to "Clear search", clear_search_path, class: "ml-2 font-medium text-blue-600 hover:text-blue-500", data: { turbo_frame: "source_monitor_sources_table" } %>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
43
|
+
|
|
44
|
+
<% if @fetch_interval_filter.present? %>
|
|
45
|
+
<% filter_label = fetch_interval_filter_label(@selected_fetch_interval_bucket, @fetch_interval_filter) %>
|
|
46
|
+
<div class="mt-1 flex items-center justify-between gap-2">
|
|
47
|
+
<span>Filtered by fetch interval <%= filter_label %>.</span>
|
|
48
|
+
<%= link_to "Clear interval filter",
|
|
49
|
+
fetch_interval_bucket_path(@selected_fetch_interval_bucket, @search_params, selected: true),
|
|
50
|
+
class: "font-medium text-blue-600 hover:text-blue-500" %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
|
|
56
|
+
<thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
57
|
+
<tr>
|
|
58
|
+
<th scope="col"
|
|
59
|
+
class="px-6 py-3"
|
|
60
|
+
data-sort-column="name"
|
|
61
|
+
aria-sort="<%= table_sort_aria(@q, :name) %>">
|
|
62
|
+
<span class="inline-flex items-center gap-1">
|
|
63
|
+
<%= table_sort_link(
|
|
64
|
+
@q,
|
|
65
|
+
:name,
|
|
66
|
+
"Source",
|
|
67
|
+
frame: "source_monitor_sources_table",
|
|
68
|
+
default_order: :asc,
|
|
69
|
+
secondary: ["created_at desc"],
|
|
70
|
+
html_options: {
|
|
71
|
+
class: "inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600 hover:text-slate-900 focus:outline-none"
|
|
72
|
+
}
|
|
73
|
+
) %>
|
|
74
|
+
<span class="text-[11px] text-slate-400" aria-hidden="true"><%= table_sort_arrow(@q, :name) %></span>
|
|
75
|
+
</span>
|
|
76
|
+
</th>
|
|
77
|
+
<th scope="col" class="px-6 py-3">Status</th>
|
|
78
|
+
<th scope="col"
|
|
79
|
+
class="px-6 py-3"
|
|
80
|
+
data-sort-column="fetch_interval_minutes"
|
|
81
|
+
aria-sort="<%= table_sort_aria(@q, :fetch_interval_minutes) %>">
|
|
82
|
+
<span class="inline-flex items-center gap-1">
|
|
83
|
+
<%= table_sort_link(
|
|
84
|
+
@q,
|
|
85
|
+
:fetch_interval_minutes,
|
|
86
|
+
"Fetch Interval",
|
|
87
|
+
frame: "source_monitor_sources_table",
|
|
88
|
+
default_order: :asc,
|
|
89
|
+
secondary: ["created_at desc"],
|
|
90
|
+
html_options: {
|
|
91
|
+
class: "inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600 hover:text-slate-900 focus:outline-none"
|
|
92
|
+
}
|
|
93
|
+
) %>
|
|
94
|
+
<span class="text-[11px] text-slate-400" aria-hidden="true"><%= table_sort_arrow(@q, :fetch_interval_minutes) %></span>
|
|
95
|
+
</span>
|
|
96
|
+
</th>
|
|
97
|
+
<th scope="col"
|
|
98
|
+
class="px-6 py-3"
|
|
99
|
+
data-sort-column="items_count"
|
|
100
|
+
aria-sort="<%= table_sort_aria(@q, :items_count) %>">
|
|
101
|
+
<span class="inline-flex items-center gap-1">
|
|
102
|
+
<%= table_sort_link(
|
|
103
|
+
@q,
|
|
104
|
+
:items_count,
|
|
105
|
+
"Items",
|
|
106
|
+
frame: "source_monitor_sources_table",
|
|
107
|
+
default_order: :desc,
|
|
108
|
+
secondary: ["created_at desc"],
|
|
109
|
+
html_options: {
|
|
110
|
+
class: "inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600 hover:text-slate-900 focus:outline-none"
|
|
111
|
+
}
|
|
112
|
+
) %>
|
|
113
|
+
<span class="text-[11px] text-slate-400" aria-hidden="true"><%= table_sort_arrow(@q, :items_count) %></span>
|
|
114
|
+
</span>
|
|
115
|
+
</th>
|
|
116
|
+
<th scope="col" class="px-6 py-3">New Items / Day</th>
|
|
117
|
+
<th scope="col"
|
|
118
|
+
class="px-6 py-3"
|
|
119
|
+
data-sort-column="last_fetched_at"
|
|
120
|
+
aria-sort="<%= table_sort_aria(@q, :last_fetched_at) %>">
|
|
121
|
+
<span class="inline-flex items-center gap-1">
|
|
122
|
+
<%= table_sort_link(
|
|
123
|
+
@q,
|
|
124
|
+
:last_fetched_at,
|
|
125
|
+
"Last Fetch",
|
|
126
|
+
frame: "source_monitor_sources_table",
|
|
127
|
+
default_order: :desc,
|
|
128
|
+
secondary: ["created_at desc"],
|
|
129
|
+
html_options: {
|
|
130
|
+
class: "inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600 hover:text-slate-900 focus:outline-none"
|
|
131
|
+
}
|
|
132
|
+
) %>
|
|
133
|
+
<span class="text-[11px] text-slate-400" aria-hidden="true"><%= table_sort_arrow(@q, :last_fetched_at) %></span>
|
|
134
|
+
</span>
|
|
135
|
+
</th>
|
|
136
|
+
<th scope="col" class="px-6 py-3"></th>
|
|
137
|
+
</tr>
|
|
138
|
+
</thead>
|
|
139
|
+
<tbody id="source_monitor_sources_table_body" class="divide-y divide-slate-100 text-slate-700">
|
|
140
|
+
<%= render partial: "source_monitor/sources/row",
|
|
141
|
+
collection: @sources,
|
|
142
|
+
as: :source,
|
|
143
|
+
locals: {
|
|
144
|
+
item_activity_rates: @item_activity_rates,
|
|
145
|
+
search_params: @search_params
|
|
146
|
+
} %>
|
|
147
|
+
|
|
148
|
+
<%= render("source_monitor/sources/empty_state_row") if @sources.blank? %>
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
<% end %>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<div class="mx-auto max-w-2xl py-10">
|
|
2
|
+
<h1 class="text-3xl font-semibold">New Source</h1>
|
|
3
|
+
|
|
4
|
+
<% if @source.errors.any? %>
|
|
5
|
+
<div class="mt-4 rounded border border-red-300 bg-red-50 p-4">
|
|
6
|
+
<h2 class="font-medium text-red-700">Please fix the following:</h2>
|
|
7
|
+
<ul class="mt-2 list-disc space-y-1 pl-5 text-red-700">
|
|
8
|
+
<% @source.errors.full_messages.each do |message| %>
|
|
9
|
+
<li><%= message %></li>
|
|
10
|
+
<% end %>
|
|
11
|
+
</ul>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<div class="mt-6">
|
|
16
|
+
<%= render "form", source: @source %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="mt-6">
|
|
20
|
+
<%= link_to "Back", source_monitor.sources_path, class: "text-blue-600 hover:underline" %>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|