source_monitor 0.7.0 → 0.8.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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/release.md +45 -22
  3. data/.claude/skills/sm-configure/SKILL.md +10 -1
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +44 -0
  5. data/.claude/skills/sm-host-setup/reference/initializer-template.md +17 -0
  6. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +2 -0
  7. data/.claude/skills/sm-job/reference/job-conventions.md +26 -0
  8. data/.claude/skills/sm-upgrade/reference/version-history.md +22 -0
  9. data/.gitignore +10 -0
  10. data/AGENTS.md +1 -1
  11. data/CHANGELOG.md +56 -0
  12. data/CLAUDE.md +11 -5
  13. data/Gemfile.lock +1 -1
  14. data/README.md +6 -4
  15. data/VERSION +1 -1
  16. data/app/assets/builds/source_monitor/application.css +43 -0
  17. data/app/assets/builds/source_monitor/application.js +127 -0
  18. data/app/assets/builds/source_monitor/application.js.map +3 -3
  19. data/app/assets/javascripts/source_monitor/application.js +2 -0
  20. data/app/assets/javascripts/source_monitor/controllers/notification_container_controller.js +138 -0
  21. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +11 -0
  22. data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +38 -0
  23. data/app/controllers/source_monitor/sources_controller.rb +11 -0
  24. data/app/helpers/source_monitor/application_helper.rb +51 -0
  25. data/app/jobs/source_monitor/favicon_fetch_job.rb +71 -0
  26. data/app/jobs/source_monitor/import_opml_job.rb +9 -0
  27. data/app/jobs/source_monitor/source_health_check_job.rb +10 -0
  28. data/app/models/source_monitor/source.rb +2 -0
  29. data/app/views/layouts/source_monitor/application.html.erb +23 -2
  30. data/app/views/source_monitor/shared/_toast.html.erb +1 -0
  31. data/app/views/source_monitor/sources/_details.html.erb +34 -5
  32. data/app/views/source_monitor/sources/_row.html.erb +11 -6
  33. data/config/routes.rb +1 -0
  34. data/docs/configuration.md +1 -1
  35. data/docs/upgrade.md +22 -0
  36. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +15 -1
  37. data/lib/source_monitor/configuration/favicons_settings.rb +42 -0
  38. data/lib/source_monitor/configuration/http_settings.rb +1 -1
  39. data/lib/source_monitor/configuration/scraping_settings.rb +1 -1
  40. data/lib/source_monitor/configuration.rb +3 -1
  41. data/lib/source_monitor/favicons/discoverer.rb +196 -0
  42. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +21 -0
  43. data/lib/source_monitor/fetching/feed_fetcher.rb +1 -0
  44. data/lib/source_monitor/http.rb +5 -3
  45. data/lib/source_monitor/version.rb +1 -1
  46. data/lib/source_monitor.rb +4 -0
  47. data/lib/tasks/test_fast.rake +11 -0
  48. data/source_monitor.gemspec +1 -1
  49. metadata +7 -93
  50. data/.vbw-planning/PROJECT.md +0 -51
  51. data/.vbw-planning/ROADMAP.md +0 -32
  52. data/.vbw-planning/SHIPPED.md +0 -63
  53. data/.vbw-planning/STATE.md +0 -27
  54. data/.vbw-planning/codebase/ARCHITECTURE.md +0 -147
  55. data/.vbw-planning/codebase/CONCERNS.md +0 -99
  56. data/.vbw-planning/codebase/CONVENTIONS.md +0 -97
  57. data/.vbw-planning/codebase/DEPENDENCIES.md +0 -100
  58. data/.vbw-planning/codebase/INDEX.md +0 -86
  59. data/.vbw-planning/codebase/META.md +0 -42
  60. data/.vbw-planning/codebase/PATTERNS.md +0 -262
  61. data/.vbw-planning/codebase/STACK.md +0 -101
  62. data/.vbw-planning/codebase/STRUCTURE.md +0 -324
  63. data/.vbw-planning/codebase/TESTING.md +0 -154
  64. data/.vbw-planning/config.json +0 -53
  65. data/.vbw-planning/discovery.json +0 -26
  66. data/.vbw-planning/milestones/default/ROADMAP.md +0 -115
  67. data/.vbw-planning/milestones/default/STATE.md +0 -82
  68. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +0 -56
  69. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +0 -187
  70. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +0 -64
  71. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +0 -137
  72. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +0 -67
  73. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +0 -142
  74. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +0 -64
  75. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +0 -138
  76. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +0 -85
  77. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +0 -147
  78. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +0 -63
  79. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +0 -129
  80. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +0 -74
  81. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +0 -154
  82. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +0 -303
  83. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +0 -510
  84. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +0 -61
  85. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +0 -161
  86. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +0 -66
  87. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +0 -132
  88. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +0 -59
  89. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +0 -171
  90. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +0 -56
  91. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +0 -152
  92. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +0 -33
  93. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +0 -42
  94. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +0 -119
  95. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +0 -52
  96. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +0 -195
  97. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +0 -79
  98. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +0 -130
  99. data/.vbw-planning/milestones/generator-enhancements/REQUIREMENTS.md +0 -72
  100. data/.vbw-planning/milestones/generator-enhancements/ROADMAP.md +0 -125
  101. data/.vbw-planning/milestones/generator-enhancements/SHIPPED.md +0 -40
  102. data/.vbw-planning/milestones/generator-enhancements/STATE.md +0 -43
  103. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-CONTEXT.md +0 -33
  104. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/01-VERIFICATION.md +0 -86
  105. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01-SUMMARY.md +0 -61
  106. data/.vbw-planning/milestones/generator-enhancements/phases/01-generator-steps/PLAN-01.md +0 -380
  107. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/02-VERIFICATION.md +0 -78
  108. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01-SUMMARY.md +0 -46
  109. data/.vbw-planning/milestones/generator-enhancements/phases/02-verification/PLAN-01.md +0 -500
  110. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/03-VERIFICATION.md +0 -89
  111. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01-SUMMARY.md +0 -48
  112. data/.vbw-planning/milestones/generator-enhancements/phases/03-docs-alignment/PLAN-01.md +0 -456
  113. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/04-VERIFICATION.md +0 -129
  114. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01-SUMMARY.md +0 -70
  115. data/.vbw-planning/milestones/generator-enhancements/phases/04-dashboard-ux/PLAN-01.md +0 -747
  116. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/05-VERIFICATION.md +0 -156
  117. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01-SUMMARY.md +0 -69
  118. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-01.md +0 -455
  119. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02-SUMMARY.md +0 -39
  120. data/.vbw-planning/milestones/generator-enhancements/phases/05-active-storage-images/PLAN-02.md +0 -488
  121. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/06-VERIFICATION.md +0 -100
  122. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01-SUMMARY.md +0 -37
  123. data/.vbw-planning/milestones/generator-enhancements/phases/06-netflix-feed-fix/PLAN-01.md +0 -345
  124. data/.vbw-planning/milestones/upgrade-assurance/REQUIREMENTS.md +0 -80
  125. data/.vbw-planning/milestones/upgrade-assurance/ROADMAP.md +0 -75
  126. data/.vbw-planning/milestones/upgrade-assurance/STATE.md +0 -29
  127. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/01-VERIFICATION.md +0 -144
  128. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01-SUMMARY.md +0 -43
  129. data/.vbw-planning/milestones/upgrade-assurance/phases/01-upgrade-command/PLAN-01.md +0 -405
  130. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01-SUMMARY.md +0 -27
  131. data/.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01.md +0 -303
  132. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/03-VERIFICATION.md +0 -380
  133. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01-SUMMARY.md +0 -36
  134. data/.vbw-planning/milestones/upgrade-assurance/phases/03-upgrade-skill-docs/PLAN-01.md +0 -652
  135. data/.vbw-planning/phases/01-aia-certificate-resolution/.context-dev.md +0 -17
  136. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01-SUMMARY.md +0 -26
  137. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-01.md +0 -71
  138. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02-SUMMARY.md +0 -16
  139. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-02.md +0 -56
  140. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03-SUMMARY.md +0 -17
  141. data/.vbw-planning/phases/01-aia-certificate-resolution/PLAN-03.md +0 -98
@@ -1,747 +0,0 @@
1
- ---
2
- phase: 4
3
- plan: "01"
4
- title: dashboard-url-display-and-clickable-links
5
- type: execute
6
- wave: 1
7
- depends_on: []
8
- cross_phase_deps: []
9
- autonomous: true
10
- effort_override: thorough
11
- skills_used: []
12
- files_modified:
13
- - lib/source_monitor/dashboard/recent_activity.rb
14
- - lib/source_monitor/dashboard/recent_activity_presenter.rb
15
- - lib/source_monitor/dashboard/queries/recent_activity_query.rb
16
- - lib/source_monitor/logs/table_presenter.rb
17
- - app/helpers/source_monitor/application_helper.rb
18
- - app/views/source_monitor/dashboard/_recent_activity.html.erb
19
- - app/views/source_monitor/logs/index.html.erb
20
- - app/views/source_monitor/sources/_row.html.erb
21
- - app/views/source_monitor/sources/_details.html.erb
22
- - app/views/source_monitor/items/_details.html.erb
23
- - test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb
24
- - test/lib/source_monitor/logs/table_presenter_test.rb
25
- - test/helpers/source_monitor/application_helper_test.rb
26
- must_haves:
27
- truths:
28
- - "Running `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb` exits 0 with 0 failures"
29
- - "Running `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/logs/table_presenter_test.rb` exits 0 with 0 failures"
30
- - "Running `bin/rails test` exits 0 with 874+ runs and 0 failures"
31
- - "Running `bin/rubocop` exits 0 with 0 offenses"
32
- artifacts:
33
- - path: "lib/source_monitor/dashboard/recent_activity.rb"
34
- provides: "Event struct with source_feed_url field for domain display"
35
- contains: "source_feed_url"
36
- - path: "lib/source_monitor/dashboard/recent_activity_presenter.rb"
37
- provides: "Fetch events include source domain, scrape events include item URL (REQ-22)"
38
- contains: "source_domain"
39
- - path: "lib/source_monitor/dashboard/queries/recent_activity_query.rb"
40
- provides: "SQL JOINs to pull feed_url for fetch events and item url for scrape events"
41
- contains: "feed_url"
42
- - path: "lib/source_monitor/logs/table_presenter.rb"
43
- provides: "Row#url_label returns domain for fetch rows, item URL for scrape rows"
44
- contains: "url_label"
45
- - path: "app/helpers/source_monitor/application_helper.rb"
46
- provides: "external_link_to helper that adds target=_blank, rel=noopener, and external-link icon"
47
- contains: "external_link_to"
48
- - path: "app/views/source_monitor/dashboard/_recent_activity.html.erb"
49
- provides: "URL info displayed below description for fetch and scrape events"
50
- contains: "url_display"
51
- - path: "app/views/source_monitor/sources/_row.html.erb"
52
- provides: "Feed URL in source row is a clickable external link"
53
- contains: "external_link_to"
54
- - path: "app/views/source_monitor/sources/_details.html.erb"
55
- provides: "Website URL and Feed URL are clickable external links"
56
- contains: "external_link_to"
57
- - path: "app/views/source_monitor/items/_details.html.erb"
58
- provides: "Item URL and Canonical URL are clickable external links"
59
- contains: "external_link_to"
60
- key_links:
61
- - from: "recent_activity_query.rb#fetch_log_sql"
62
- to: "REQ-22"
63
- via: "JOIN sources to pull feed_url, displayed as domain on dashboard"
64
- - from: "recent_activity_query.rb#scrape_log_sql"
65
- to: "REQ-22"
66
- via: "JOIN items to pull item url, displayed on dashboard"
67
- - from: "application_helper.rb#external_link_to"
68
- to: "REQ-23"
69
- via: "All external URLs use this helper for target=_blank + external-link icon"
70
- - from: "sources/_row.html.erb"
71
- to: "REQ-23"
72
- via: "Feed URL in source index row is clickable"
73
- - from: "sources/_details.html.erb"
74
- to: "REQ-23"
75
- via: "Website URL and feed URL on source detail page are clickable"
76
- - from: "items/_details.html.erb"
77
- to: "REQ-23"
78
- via: "Item URL and canonical URL are clickable"
79
- ---
80
- <objective>
81
- Show source domain (RSS fetch logs) and item URL (scrape logs) in dashboard recent activity and logs table for both success and failure entries (REQ-22). Make all external URLs (feed URLs, website URLs, item URLs) clickable links that open in a new tab with an external-link icon indicator across dashboard, sources index, source detail, and item detail views (REQ-23).
82
- </objective>
83
- <context>
84
- @lib/source_monitor/dashboard/recent_activity.rb -- Event struct with keyword_init. Currently has: type, id, occurred_at, success, items_created, items_updated, scraper_adapter, item_title, item_url, source_name, source_id. Add `source_feed_url` field so the presenter can extract the domain for fetch events. The `item_url` field already exists but is currently NULL for scrape events in the SQL query.
85
-
86
- @lib/source_monitor/dashboard/recent_activity_presenter.rb -- Transforms Event structs into view-model hashes with keys: label, description, status, type, time, path. `fetch_event` currently shows "N created / N updated" as description. Add `url_display` key to the hash: for fetch events, extract domain from `event.source_feed_url` using `URI.parse(url).host`; for scrape events, use `event.item_url`. Both success and failure events get URL info since the source/item is known regardless of outcome.
87
-
88
- @lib/source_monitor/dashboard/queries/recent_activity_query.rb -- Raw SQL UNION query. `fetch_log_sql` currently selects `NULL AS source_name` and `NULL AS item_url`. Change: (1) JOIN sources table to fetch_logs and SELECT `feed_url AS source_feed_url` (new column in UNION), (2) For scrape_log_sql, JOIN items table and SELECT `items.url AS item_url` (currently NULL). Add `source_feed_url` to the outer SELECT and `build_event`. All three sub-queries must have matching column count, so add `NULL AS source_feed_url` to scrape_log_sql and item_sql.
89
-
90
- @lib/source_monitor/logs/table_presenter.rb -- Row class wraps LogEntry records. Add `url_label` method: for fetch rows, extract domain from `entry.source&.feed_url`; for scrape rows, return `entry.item&.url`. This will be displayed in the logs table. The LogEntry model already has `belongs_to :source` and `belongs_to :item` so the associations are available.
91
-
92
- @app/helpers/source_monitor/application_helper.rb -- Add `external_link_to(label, url, **options)` helper that wraps `link_to` with `target: "_blank"`, `rel: "noopener noreferrer"`, and appends a small external-link SVG icon. This DRYs up the pattern used across all views. The helper should handle nil/blank URLs gracefully (return label as plain text). Include Tailwind classes for consistent styling.
93
-
94
- @app/views/source_monitor/dashboard/_recent_activity.html.erb -- Currently shows `event[:description]` as text. Add `event[:url_display]` rendering below the description as a smaller, muted line showing the URL/domain. Use the external_link_to helper to make it clickable. Only render if url_display is present.
95
-
96
- @app/views/source_monitor/logs/index.html.erb -- The table has columns: Started, Type, Subject, Source, HTTP/Adapter, Result, Metrics, detail link. The URL info fits naturally into the existing Subject column as a second line below the primary_label, similar to how the sources row shows feed_url below the name. Use `row.url_label` for this.
97
-
98
- @app/views/source_monitor/sources/_row.html.erb -- Line 32: `<div class="text-xs text-slate-500 truncate max-w-xs"><%= source.feed_url %></div>` -- plain text. Replace with `external_link_to` helper call, truncating the display text.
99
-
100
- @app/views/source_monitor/sources/_details.html.erb -- Line 28: `Feed URL: <%= source.feed_url %>` -- plain text. Replace with external_link_to. Line 140: `"Website" => (source.website_url.presence || "-")` -- plain text in details hash. Replace with external_link_to call for the value.
101
-
102
- @app/views/source_monitor/items/_details.html.erb -- Lines 56-57: `"URL" => item.url` and `"Canonical URL" => item.canonical_url || "-"` -- plain text in details hash. Replace both with external_link_to helper calls.
103
-
104
- @test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb -- 2 existing tests. Add tests for: (a) fetch event includes url_display with domain from source_feed_url, (b) scrape event includes url_display with item URL, (c) fetch event with nil source_feed_url omits url_display, (d) failure fetch event still includes url_display.
105
-
106
- @test/lib/source_monitor/logs/table_presenter_test.rb -- 1 existing test with comprehensive assertions. Add assertions for `url_label` on fetch_row (domain from source feed_url) and scrape_row (item URL).
107
-
108
- **Rationale:** The dashboard is the primary monitoring surface. When a fetch fails, operators need to immediately see which feed URL was involved without clicking through. Similarly, scrape failures should show the item URL. Making all external URLs clickable with new-tab behavior follows standard UX conventions for dashboards that reference external resources.
109
- </context>
110
- <tasks>
111
- <task type="auto">
112
- <name>add-external-link-helper-and-tests</name>
113
- <files>
114
- app/helpers/source_monitor/application_helper.rb
115
- test/helpers/source_monitor/application_helper_test.rb
116
- </files>
117
- <action>
118
- **Add `external_link_to` helper to `app/helpers/source_monitor/application_helper.rb`:**
119
-
120
- Add the following public method before the `private` keyword (around line 215):
121
-
122
- ```ruby
123
- # Renders a clickable link that opens in a new tab with an external-link icon.
124
- # Returns the label as plain text if the URL is blank.
125
- def external_link_to(label, url, **options)
126
- return label if url.blank?
127
-
128
- css = options.delete(:class) || "text-blue-600 hover:text-blue-500"
129
- link_to(url, target: "_blank", rel: "noopener noreferrer", class: css, title: url, **options) do
130
- safe_join([label, " ", external_link_icon])
131
- end
132
- end
133
- ```
134
-
135
- Also add a private `external_link_icon` method after the `private` keyword:
136
-
137
- ```ruby
138
- def external_link_icon
139
- tag.svg(
140
- class: "inline-block h-3 w-3 text-slate-400",
141
- xmlns: "http://www.w3.org/2000/svg",
142
- fill: "none",
143
- viewBox: "0 0 24 24",
144
- stroke_width: "2",
145
- stroke: "currentColor",
146
- aria: { hidden: "true" }
147
- ) do
148
- safe_join([
149
- tag.path(
150
- stroke_linecap: "round",
151
- stroke_linejoin: "round",
152
- d: "M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
153
- )
154
- ])
155
- end
156
- end
157
- ```
158
-
159
- Also add a public `domain_from_url(url)` helper (used by presenters and views) before the `private` keyword:
160
-
161
- ```ruby
162
- # Extracts the domain from a URL, returning nil if parsing fails.
163
- def domain_from_url(url)
164
- return nil if url.blank?
165
-
166
- URI.parse(url.to_s).host
167
- rescue URI::InvalidURIError
168
- nil
169
- end
170
- ```
171
-
172
- **Add/update test file `test/helpers/source_monitor/application_helper_test.rb`:**
173
-
174
- Create or update the test file with tests for `external_link_to` and `domain_from_url`:
175
-
176
- ```ruby
177
- # frozen_string_literal: true
178
-
179
- require "test_helper"
180
-
181
- module SourceMonitor
182
- class ApplicationHelperTest < ActionView::TestCase
183
- include SourceMonitor::ApplicationHelper
184
-
185
- test "external_link_to renders link with target blank and icon" do
186
- result = external_link_to("Example", "https://example.com")
187
- assert_includes result, 'target="_blank"'
188
- assert_includes result, 'rel="noopener noreferrer"'
189
- assert_includes result, "Example"
190
- assert_includes result, "<svg"
191
- end
192
-
193
- test "external_link_to returns plain label when url is blank" do
194
- result = external_link_to("No URL", nil)
195
- assert_equal "No URL", result
196
- end
197
-
198
- test "external_link_to returns plain label when url is empty string" do
199
- result = external_link_to("No URL", "")
200
- assert_equal "No URL", result
201
- end
202
-
203
- test "external_link_to accepts custom css class" do
204
- result = external_link_to("Link", "https://example.com", class: "custom-class")
205
- assert_includes result, "custom-class"
206
- end
207
-
208
- test "domain_from_url extracts host from valid URL" do
209
- assert_equal "example.com", domain_from_url("https://example.com/path")
210
- assert_equal "blog.example.org", domain_from_url("https://blog.example.org/feed.xml")
211
- end
212
-
213
- test "domain_from_url returns nil for blank URL" do
214
- assert_nil domain_from_url(nil)
215
- assert_nil domain_from_url("")
216
- end
217
-
218
- test "domain_from_url returns nil for invalid URL" do
219
- assert_nil domain_from_url("not a url %%%")
220
- end
221
- end
222
- end
223
- ```
224
- </action>
225
- <verify>
226
- Run `PARALLEL_WORKERS=1 bin/rails test test/helpers/source_monitor/application_helper_test.rb` -- all tests pass. Run `bin/rubocop app/helpers/source_monitor/application_helper.rb test/helpers/source_monitor/application_helper_test.rb` -- 0 offenses.
227
- </verify>
228
- <done>
229
- external_link_to helper renders links with target=_blank, rel=noopener noreferrer, and external-link SVG icon. domain_from_url extracts hostnames from URLs. Both handle nil/blank gracefully. 7 tests pass. REQ-23 foundation established.
230
- </done>
231
- </task>
232
- <task type="auto">
233
- <name>add-url-info-to-recent-activity-query-and-presenter</name>
234
- <files>
235
- lib/source_monitor/dashboard/recent_activity.rb
236
- lib/source_monitor/dashboard/queries/recent_activity_query.rb
237
- lib/source_monitor/dashboard/recent_activity_presenter.rb
238
- test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb
239
- </files>
240
- <action>
241
- **Step 1: Add `source_feed_url` to the Event struct in `lib/source_monitor/dashboard/recent_activity.rb`:**
242
-
243
- Add `:source_feed_url` to the Struct fields, after `:source_id`:
244
-
245
- ```ruby
246
- Event = Struct.new(
247
- :type,
248
- :id,
249
- :occurred_at,
250
- :success,
251
- :items_created,
252
- :items_updated,
253
- :scraper_adapter,
254
- :item_title,
255
- :item_url,
256
- :source_name,
257
- :source_id,
258
- :source_feed_url,
259
- keyword_init: true
260
- )
261
- ```
262
-
263
- **Step 2: Update `lib/source_monitor/dashboard/queries/recent_activity_query.rb`:**
264
-
265
- (a) Add `source_feed_url` to the outer SELECT in `unified_sql_template`:
266
- ```ruby
267
- SELECT resource_type,
268
- resource_id,
269
- occurred_at,
270
- success_flag,
271
- items_created,
272
- items_updated,
273
- scraper_adapter,
274
- item_title,
275
- item_url,
276
- source_name,
277
- source_id,
278
- source_feed_url
279
- FROM (
280
- ```
281
-
282
- (b) Update `fetch_log_sql` to JOIN sources and select feed_url:
283
- ```ruby
284
- def fetch_log_sql
285
- <<~SQL
286
- SELECT
287
- '#{EVENT_TYPE_FETCH}' AS resource_type,
288
- #{SourceMonitor::FetchLog.quoted_table_name}.id AS resource_id,
289
- #{SourceMonitor::FetchLog.quoted_table_name}.started_at AS occurred_at,
290
- CASE WHEN #{SourceMonitor::FetchLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
291
- #{SourceMonitor::FetchLog.quoted_table_name}.items_created AS items_created,
292
- #{SourceMonitor::FetchLog.quoted_table_name}.items_updated AS items_updated,
293
- NULL AS scraper_adapter,
294
- NULL AS item_title,
295
- NULL AS item_url,
296
- #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
297
- #{SourceMonitor::FetchLog.quoted_table_name}.source_id AS source_id,
298
- #{SourceMonitor::Source.quoted_table_name}.feed_url AS source_feed_url
299
- FROM #{SourceMonitor::FetchLog.quoted_table_name}
300
- LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
301
- ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::FetchLog.quoted_table_name}.source_id
302
- SQL
303
- end
304
- ```
305
-
306
- (c) Update `scrape_log_sql` to also JOIN items and select item url, plus add NULL source_feed_url:
307
- ```ruby
308
- def scrape_log_sql
309
- <<~SQL
310
- SELECT
311
- '#{EVENT_TYPE_SCRAPE}' AS resource_type,
312
- #{SourceMonitor::ScrapeLog.quoted_table_name}.id AS resource_id,
313
- #{SourceMonitor::ScrapeLog.quoted_table_name}.started_at AS occurred_at,
314
- CASE WHEN #{SourceMonitor::ScrapeLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
315
- NULL AS items_created,
316
- NULL AS items_updated,
317
- #{SourceMonitor::ScrapeLog.quoted_table_name}.scraper_adapter AS scraper_adapter,
318
- NULL AS item_title,
319
- #{SourceMonitor::Item.quoted_table_name}.url AS item_url,
320
- #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
321
- #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id AS source_id,
322
- NULL AS source_feed_url
323
- FROM #{SourceMonitor::ScrapeLog.quoted_table_name}
324
- LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
325
- ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id
326
- LEFT JOIN #{SourceMonitor::Item.quoted_table_name}
327
- ON #{SourceMonitor::Item.quoted_table_name}.id = #{SourceMonitor::ScrapeLog.quoted_table_name}.item_id
328
- SQL
329
- end
330
- ```
331
-
332
- (d) Update `item_sql` to add NULL source_feed_url:
333
- ```ruby
334
- # Add after the source_id line:
335
- NULL AS source_feed_url
336
- ```
337
-
338
- (e) Update `build_event` to include the new field:
339
- ```ruby
340
- source_feed_url: row["source_feed_url"]
341
- ```
342
-
343
- **Step 3: Update `lib/source_monitor/dashboard/recent_activity_presenter.rb`:**
344
-
345
- (a) Add `url_display` and `url_href` keys to `fetch_event`:
346
- ```ruby
347
- def fetch_event(event)
348
- domain = source_domain(event.source_feed_url)
349
- {
350
- label: "Fetch ##{event.id}",
351
- description: "#{event.items_created.to_i} created / #{event.items_updated.to_i} updated",
352
- status: event.success? ? :success : :failure,
353
- type: :fetch,
354
- time: event.occurred_at,
355
- path: url_helpers.fetch_log_path(event.id),
356
- url_display: domain,
357
- url_href: event.source_feed_url
358
- }
359
- end
360
- ```
361
-
362
- (b) Add `url_display` and `url_href` keys to `scrape_event`:
363
- ```ruby
364
- def scrape_event(event)
365
- {
366
- label: "Scrape ##{event.id}",
367
- description: (event.scraper_adapter.presence || "Scraper"),
368
- status: event.success? ? :success : :failure,
369
- type: :scrape,
370
- time: event.occurred_at,
371
- path: url_helpers.scrape_log_path(event.id),
372
- url_display: event.item_url,
373
- url_href: event.item_url
374
- }
375
- end
376
- ```
377
-
378
- (c) Add private `source_domain` method:
379
- ```ruby
380
- def source_domain(feed_url)
381
- return nil if feed_url.blank?
382
-
383
- URI.parse(feed_url.to_s).host
384
- rescue URI::InvalidURIError
385
- nil
386
- end
387
- ```
388
-
389
- **Step 4: Update tests in `test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb`:**
390
-
391
- Add the following tests after the existing ones:
392
-
393
- ```ruby
394
- test "fetch event includes source domain as url_display" do
395
- event = SourceMonitor::Dashboard::RecentActivity::Event.new(
396
- type: :fetch_log,
397
- id: 10,
398
- occurred_at: Time.current,
399
- success: true,
400
- items_created: 2,
401
- items_updated: 0,
402
- source_feed_url: "https://blog.example.com/feed.xml"
403
- )
404
-
405
- presenter = SourceMonitor::Dashboard::RecentActivityPresenter.new(
406
- [event],
407
- url_helpers: SourceMonitor::Engine.routes.url_helpers
408
- )
409
-
410
- result = presenter.to_a.first
411
- assert_equal "blog.example.com", result[:url_display]
412
- assert_equal "https://blog.example.com/feed.xml", result[:url_href]
413
- end
414
-
415
- test "fetch event with nil source_feed_url has nil url_display" do
416
- event = SourceMonitor::Dashboard::RecentActivity::Event.new(
417
- type: :fetch_log,
418
- id: 11,
419
- occurred_at: Time.current,
420
- success: false,
421
- items_created: 0,
422
- items_updated: 0,
423
- source_feed_url: nil
424
- )
425
-
426
- presenter = SourceMonitor::Dashboard::RecentActivityPresenter.new(
427
- [event],
428
- url_helpers: SourceMonitor::Engine.routes.url_helpers
429
- )
430
-
431
- result = presenter.to_a.first
432
- assert_nil result[:url_display]
433
- assert_equal :failure, result[:status]
434
- end
435
-
436
- test "failure fetch event still includes url_display" do
437
- event = SourceMonitor::Dashboard::RecentActivity::Event.new(
438
- type: :fetch_log,
439
- id: 12,
440
- occurred_at: Time.current,
441
- success: false,
442
- items_created: 0,
443
- items_updated: 0,
444
- source_feed_url: "https://failing-feed.example.org/rss"
445
- )
446
-
447
- presenter = SourceMonitor::Dashboard::RecentActivityPresenter.new(
448
- [event],
449
- url_helpers: SourceMonitor::Engine.routes.url_helpers
450
- )
451
-
452
- result = presenter.to_a.first
453
- assert_equal "failing-feed.example.org", result[:url_display]
454
- assert_equal :failure, result[:status]
455
- end
456
-
457
- test "scrape event includes item url as url_display" do
458
- event = SourceMonitor::Dashboard::RecentActivity::Event.new(
459
- type: :scrape_log,
460
- id: 20,
461
- occurred_at: Time.current,
462
- success: true,
463
- scraper_adapter: "readability",
464
- item_url: "https://example.com/articles/42"
465
- )
466
-
467
- presenter = SourceMonitor::Dashboard::RecentActivityPresenter.new(
468
- [event],
469
- url_helpers: SourceMonitor::Engine.routes.url_helpers
470
- )
471
-
472
- result = presenter.to_a.first
473
- assert_equal "https://example.com/articles/42", result[:url_display]
474
- assert_equal "https://example.com/articles/42", result[:url_href]
475
- end
476
- ```
477
- </action>
478
- <verify>
479
- Run `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb` -- all 6 tests pass. Run `bin/rubocop lib/source_monitor/dashboard/recent_activity.rb lib/source_monitor/dashboard/recent_activity_presenter.rb lib/source_monitor/dashboard/queries/recent_activity_query.rb` -- 0 offenses.
480
- </verify>
481
- <done>
482
- RecentActivityQuery now JOINs sources for fetch logs (pulling feed_url) and JOINs items for scrape logs (pulling item url). Presenter extracts domain for fetch events and passes through item URL for scrape events. Both success and failure events include URL info. 6 tests pass. REQ-22 core data layer complete.
483
- </done>
484
- </task>
485
- <task type="auto">
486
- <name>add-url-to-logs-table-presenter</name>
487
- <files>
488
- lib/source_monitor/logs/table_presenter.rb
489
- test/lib/source_monitor/logs/table_presenter_test.rb
490
- </files>
491
- <action>
492
- **Step 1: Add `url_label` and `url_href` methods to `lib/source_monitor/logs/table_presenter.rb` Row class:**
493
-
494
- Add these public methods after the `primary_path` method (around line 62):
495
-
496
- ```ruby
497
- def url_label
498
- if fetch?
499
- domain_from_feed_url
500
- elsif scrape?
501
- entry.item&.url
502
- end
503
- end
504
-
505
- def url_href
506
- if fetch?
507
- entry.source&.feed_url
508
- elsif scrape?
509
- entry.item&.url
510
- end
511
- end
512
- ```
513
-
514
- Add a private helper method after the existing `attr_reader` line (around line 144):
515
-
516
- ```ruby
517
- def domain_from_feed_url
518
- feed_url = entry.source&.feed_url
519
- return nil if feed_url.blank?
520
-
521
- URI.parse(feed_url.to_s).host
522
- rescue URI::InvalidURIError
523
- nil
524
- end
525
- ```
526
-
527
- **Step 2: Update tests in `test/lib/source_monitor/logs/table_presenter_test.rb`:**
528
-
529
- Add assertions for `url_label` and `url_href` inside the existing "builds typed row view models" test:
530
-
531
- After the fetch_row assertions block (around line 91), add:
532
- ```ruby
533
- assert_match(/presenter-source\.test/, fetch_row.url_label) if @source.feed_url.present?
534
- assert_equal @source.feed_url, fetch_row.url_href
535
- ```
536
-
537
- Wait -- the test creates a source via `create_source!(name: "Presenter Source")`. Check what feed_url that gives. The factory likely sets a default feed_url. Add these assertions:
538
-
539
- For the fetch_row section, after `assert_equal source_path(@source), fetch_row.primary_path`, add:
540
- ```ruby
541
- assert_equal URI.parse(@source.feed_url).host, fetch_row.url_label
542
- assert_equal @source.feed_url, fetch_row.url_href
543
- ```
544
-
545
- For the scrape_row section, after `assert_equal item_path(@item), scrape_row.primary_path`, add:
546
- ```ruby
547
- assert_equal "https://example.com/articles/presenter", scrape_row.url_label
548
- assert_equal "https://example.com/articles/presenter", scrape_row.url_href
549
- ```
550
-
551
- For the health_row section, after the last assertion, add:
552
- ```ruby
553
- assert_nil health_row.url_label
554
- assert_nil health_row.url_href
555
- ```
556
- </action>
557
- <verify>
558
- Run `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/logs/table_presenter_test.rb` -- all tests pass. Run `bin/rubocop lib/source_monitor/logs/table_presenter.rb` -- 0 offenses.
559
- </verify>
560
- <done>
561
- TablePresenter::Row now provides url_label (domain for fetches, full URL for scrapes) and url_href for linking. Health check rows return nil. Tests updated with assertions for all three row types. REQ-22 logs table data layer complete.
562
- </done>
563
- </task>
564
- <task type="auto">
565
- <name>update-dashboard-and-logs-views-with-url-display</name>
566
- <files>
567
- app/views/source_monitor/dashboard/_recent_activity.html.erb
568
- app/views/source_monitor/logs/index.html.erb
569
- </files>
570
- <action>
571
- **Step 1: Update `app/views/source_monitor/dashboard/_recent_activity.html.erb`:**
572
-
573
- After the description line (line 22-23), add a URL display line. Replace the description block:
574
-
575
- Current (lines 21-23):
576
- ```erb
577
- <div class="mt-1 text-xs text-slate-500">
578
- <%= event[:description].presence || "No additional details recorded." %>
579
- </div>
580
- ```
581
-
582
- Replace with:
583
- ```erb
584
- <div class="mt-1 text-xs text-slate-500">
585
- <%= event[:description].presence || "No additional details recorded." %>
586
- </div>
587
- <% if event[:url_display].present? %>
588
- <div class="mt-0.5 text-xs text-slate-400 truncate max-w-sm" data-testid="event-url-display">
589
- <% if event[:url_href].present? %>
590
- <%= external_link_to event[:url_display], event[:url_href], class: "text-slate-400 hover:text-blue-500" %>
591
- <% else %>
592
- <%= event[:url_display] %>
593
- <% end %>
594
- </div>
595
- <% end %>
596
- ```
597
-
598
- **Step 2: Update `app/views/source_monitor/logs/index.html.erb`:**
599
-
600
- In the table body, update the Subject column (lines 131-136) to also show the URL below the primary label:
601
-
602
- Current:
603
- ```erb
604
- <td class="px-6 py-4 text-sm">
605
- <% if row.primary_path %>
606
- <%= link_to row.primary_label, row.primary_path, class: "text-blue-600 hover:text-blue-500" %>
607
- <% else %>
608
- <%= row.primary_label %>
609
- <% end %>
610
- </td>
611
- ```
612
-
613
- Replace with:
614
- ```erb
615
- <td class="px-6 py-4 text-sm">
616
- <% if row.primary_path %>
617
- <%= link_to row.primary_label, row.primary_path, class: "text-blue-600 hover:text-blue-500" %>
618
- <% else %>
619
- <%= row.primary_label %>
620
- <% end %>
621
- <% if row.url_label.present? %>
622
- <div class="mt-0.5 text-xs text-slate-400 truncate max-w-xs">
623
- <% if row.url_href.present? %>
624
- <%= external_link_to row.url_label, row.url_href, class: "text-slate-400 hover:text-blue-500" %>
625
- <% else %>
626
- <%= row.url_label %>
627
- <% end %>
628
- </div>
629
- <% end %>
630
- </td>
631
- ```
632
-
633
- This preserves the existing layout while adding URL context below the subject line, using the same pattern as the sources index row (feed URL below source name).
634
- </action>
635
- <verify>
636
- Run `bin/rubocop` on the modified .erb files (RuboCop may not lint .erb but confirm no syntax errors). Run `bin/rails test` to ensure no rendering errors. Visually inspect: the URL line should appear below the description/subject in both the dashboard recent activity panel and the logs table.
637
- </verify>
638
- <done>
639
- Dashboard recent activity shows source domain for fetch events and item URL for scrape events. Logs table shows URL info below the subject column. Both use external_link_to for clickable links with new-tab behavior. Layout preserved with muted styling. REQ-22 view layer complete.
640
- </done>
641
- </task>
642
- <task type="auto">
643
- <name>make-external-urls-clickable-across-views</name>
644
- <files>
645
- app/views/source_monitor/sources/_row.html.erb
646
- app/views/source_monitor/sources/_details.html.erb
647
- app/views/source_monitor/items/_details.html.erb
648
- </files>
649
- <action>
650
- **Step 1: Update `app/views/source_monitor/sources/_row.html.erb`:**
651
-
652
- Replace line 32:
653
- ```erb
654
- <div class="text-xs text-slate-500 truncate max-w-xs"><%= source.feed_url %></div>
655
- ```
656
-
657
- With:
658
- ```erb
659
- <div class="text-xs text-slate-500 truncate max-w-xs"><%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></div>
660
- ```
661
-
662
- **Step 2: Update `app/views/source_monitor/sources/_details.html.erb`:**
663
-
664
- (a) Replace line 28:
665
- ```erb
666
- <p class="mt-2 text-sm text-slate-500">Feed URL: <%= source.feed_url %></p>
667
- ```
668
-
669
- With:
670
- ```erb
671
- <p class="mt-2 text-sm text-slate-500">Feed URL: <%= external_link_to source.feed_url, source.feed_url, class: "text-slate-500 hover:text-blue-500" %></p>
672
- ```
673
-
674
- (b) In the details hash (around line 140), replace:
675
- ```ruby
676
- "Website" => (source.website_url.presence || "\u2014"),
677
- ```
678
-
679
- With:
680
- ```ruby
681
- "Website" => (source.website_url.present? ? external_link_to(source.website_url, source.website_url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
682
- ```
683
-
684
- Note: Since the details hash values are rendered via `<%= value %>`, and external_link_to returns an html_safe string from link_to, this will work correctly. However, you may need to use `raw` or ensure the helper returns `html_safe` content. Since `link_to` already returns safe HTML, this should work.
685
-
686
- **Step 3: Update `app/views/source_monitor/items/_details.html.erb`:**
687
-
688
- In the details hash (around lines 56-57), replace:
689
- ```ruby
690
- "URL" => item.url,
691
- "Canonical URL" => item.canonical_url || "\u2014",
692
- ```
693
-
694
- With:
695
- ```ruby
696
- "URL" => (item.url.present? ? external_link_to(item.url, item.url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
697
- "Canonical URL" => (item.canonical_url.present? ? external_link_to(item.canonical_url, item.canonical_url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
698
- ```
699
-
700
- **Step 4: Full suite verification:**
701
-
702
- Run `bin/rails test` -- all 874+ tests pass with 0 failures.
703
- Run `bin/rubocop` -- 0 offenses.
704
-
705
- Check that no existing test assertions break due to the HTML changes (controller integration tests that assert on response body content may need attention if they check for exact text matches on URLs).
706
- </action>
707
- <verify>
708
- Run `bin/rails test` -- all tests pass. Run `bin/rubocop` -- 0 offenses. Grep for `external_link_to` in the three modified view files to confirm all external URLs are now wrapped. Grep for `target="_blank"` in the rendered output would confirm new-tab behavior.
709
- </verify>
710
- <done>
711
- All external URLs are now clickable across source index rows (feed URL), source detail page (feed URL, website URL), and item detail page (URL, canonical URL). Links open in new tab with external-link icon indicator. REQ-23 fully satisfied. Full test suite passes, RuboCop clean.
712
- </done>
713
- </task>
714
- </tasks>
715
- <verification>
716
- 1. `PARALLEL_WORKERS=1 bin/rails test test/helpers/source_monitor/application_helper_test.rb` -- 7 tests pass
717
- 2. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/dashboard/recent_activity_presenter_test.rb` -- 6 tests pass
718
- 3. `PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/logs/table_presenter_test.rb` -- tests pass with url_label/url_href assertions
719
- 4. `bin/rails test` -- 874+ runs, 0 failures
720
- 5. `bin/rubocop` -- 0 offenses
721
- 6. `grep -n 'external_link_to' app/helpers/source_monitor/application_helper.rb` -- method defined
722
- 7. `grep -n 'source_feed_url' lib/source_monitor/dashboard/recent_activity.rb` -- field in Event struct
723
- 8. `grep -n 'url_display' lib/source_monitor/dashboard/recent_activity_presenter.rb` -- key in view model hash
724
- 9. `grep -n 'feed_url' lib/source_monitor/dashboard/queries/recent_activity_query.rb` -- SELECT in fetch_log_sql
725
- 10. `grep -n 'url_label' lib/source_monitor/logs/table_presenter.rb` -- method on Row
726
- 11. `grep -n 'external_link_to' app/views/source_monitor/sources/_row.html.erb` -- feed URL clickable
727
- 12. `grep -n 'external_link_to' app/views/source_monitor/sources/_details.html.erb` -- website/feed URL clickable
728
- 13. `grep -n 'external_link_to' app/views/source_monitor/items/_details.html.erb` -- item URL clickable
729
- 14. `grep -rn 'target="_blank"' app/views/source_monitor/dashboard/_recent_activity.html.erb` -- new-tab links via helper
730
- </verification>
731
- <success_criteria>
732
- - Fetch log events on the dashboard display the source domain extracted from feed_url (REQ-22)
733
- - Scrape log events on the dashboard display the item URL being scraped (REQ-22)
734
- - Both success and failure fetch/scrape events show URL info (REQ-22)
735
- - Logs table shows URL info below the subject column for fetch and scrape entries (REQ-22)
736
- - external_link_to helper renders links with target=_blank, rel=noopener noreferrer, external-link SVG icon (REQ-23)
737
- - Source index row feed URLs are clickable external links (REQ-23)
738
- - Source detail page feed URL and website URL are clickable external links (REQ-23)
739
- - Item detail page URL and canonical URL are clickable external links (REQ-23)
740
- - Existing dashboard layout is preserved (no structural changes to grid/flex layout)
741
- - All existing tests pass with no regressions
742
- - New tests cover external_link_to, domain_from_url, presenter url_display, and table presenter url_label
743
- - RuboCop clean
744
- </success_criteria>
745
- <output>
746
- .vbw-planning/phases/04-dashboard-ux/PLAN-01-SUMMARY.md
747
- </output>