source_monitor 0.12.1 → 0.12.3

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.
@@ -61,6 +61,10 @@ export default class extends Controller {
61
61
  }
62
62
  }
63
63
 
64
+ stop(event) {
65
+ event.stopPropagation();
66
+ }
67
+
64
68
  handleEscape(event) {
65
69
  if (event.key === "Escape") {
66
70
  this.close(event);
@@ -1,7 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["master", "item", "actionBar", "count"];
4
+ static targets = [
5
+ "master",
6
+ "item",
7
+ "actionBar",
8
+ "count",
9
+ "crossPageBanner",
10
+ "selectAllPagesInput",
11
+ ];
12
+ static values = { totalCandidates: Number };
5
13
 
6
14
  connect() {
7
15
  this.syncMaster();
@@ -24,14 +32,54 @@ export default class extends Controller {
24
32
  if (checkbox.disabled) return;
25
33
  checkbox.checked = checked;
26
34
  });
35
+ if (!checked) {
36
+ this.deselectAllPages();
37
+ }
27
38
  this.updateActionBar();
28
39
  }
29
40
 
30
41
  toggleItem() {
42
+ this.deselectAllPages();
31
43
  this.syncMaster();
32
44
  this.updateActionBar();
33
45
  }
34
46
 
47
+ selectAllPages() {
48
+ if (this.hasSelectAllPagesInputTarget) {
49
+ this.selectAllPagesInputTarget.disabled = false;
50
+ }
51
+ if (this.hasCrossPageBannerTarget) {
52
+ this.crossPageBannerTarget.dataset.selected = "true";
53
+ const deselect = this.crossPageBannerTarget.querySelector(
54
+ "[data-role='deselect']"
55
+ );
56
+ const select = this.crossPageBannerTarget.querySelector(
57
+ "[data-role='select']"
58
+ );
59
+ if (deselect) deselect.classList.remove("hidden");
60
+ if (select) select.classList.add("hidden");
61
+ }
62
+ this.updateCount();
63
+ }
64
+
65
+ deselectAllPages() {
66
+ if (this.hasSelectAllPagesInputTarget) {
67
+ this.selectAllPagesInputTarget.disabled = true;
68
+ }
69
+ if (this.hasCrossPageBannerTarget) {
70
+ this.crossPageBannerTarget.dataset.selected = "false";
71
+ const deselect = this.crossPageBannerTarget.querySelector(
72
+ "[data-role='deselect']"
73
+ );
74
+ const select = this.crossPageBannerTarget.querySelector(
75
+ "[data-role='select']"
76
+ );
77
+ if (deselect) deselect.classList.add("hidden");
78
+ if (select) select.classList.remove("hidden");
79
+ }
80
+ this.updateCount();
81
+ }
82
+
35
83
  syncMaster() {
36
84
  if (!this.hasMasterTarget) return;
37
85
  const selectable = this.itemTargets.filter((checkbox) => !checkbox.disabled);
@@ -44,13 +92,42 @@ export default class extends Controller {
44
92
  updateActionBar() {
45
93
  if (!this.hasActionBarTarget) return;
46
94
  const checkedCount = this.itemTargets.filter((cb) => cb.checked).length;
47
- if (this.hasCountTarget) {
48
- this.countTarget.textContent = checkedCount;
49
- }
95
+ this.updateCount();
96
+ this.updateCrossPageBanner();
50
97
  if (checkedCount > 0) {
51
98
  this.actionBarTarget.classList.remove("hidden");
52
99
  } else {
53
100
  this.actionBarTarget.classList.add("hidden");
54
101
  }
55
102
  }
103
+
104
+ updateCount() {
105
+ if (!this.hasCountTarget) return;
106
+ const isAllPages =
107
+ this.hasCrossPageBannerTarget &&
108
+ this.crossPageBannerTarget.dataset.selected === "true";
109
+ if (isAllPages && this.hasTotalCandidatesValue) {
110
+ this.countTarget.textContent = this.totalCandidatesValue;
111
+ } else {
112
+ const checkedCount = this.itemTargets.filter((cb) => cb.checked).length;
113
+ this.countTarget.textContent = checkedCount;
114
+ }
115
+ }
116
+
117
+ updateCrossPageBanner() {
118
+ if (!this.hasCrossPageBannerTarget) return;
119
+ const selectable = this.itemTargets.filter((cb) => !cb.disabled);
120
+ const allChecked =
121
+ selectable.length > 0 &&
122
+ selectable.every((cb) => cb.checked);
123
+ const hasMorePages =
124
+ this.hasTotalCandidatesValue && this.totalCandidatesValue > selectable.length;
125
+
126
+ if (allChecked && hasMorePages) {
127
+ this.crossPageBannerTarget.classList.remove("hidden");
128
+ } else {
129
+ this.crossPageBannerTarget.classList.add("hidden");
130
+ this.deselectAllPages();
131
+ }
132
+ }
56
133
  }
@@ -7,14 +7,9 @@ module SourceMonitor
7
7
  ICONS = {
8
8
  menu_dots: {
9
9
  view_box: "0 0 20 20",
10
- fill: "none",
11
- stroke: "currentColor",
12
- stroke_width: "1.5",
10
+ fill: "currentColor",
13
11
  paths: [
14
- { 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",
15
- stroke_linecap: "round", stroke_linejoin: "round" },
16
- { d: "M12 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z",
17
- stroke_linecap: "round", stroke_linejoin: "round" }
12
+ { d: "M10 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM10 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM11.5 15.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z" }
18
13
  ]
19
14
  },
20
15
  refresh: {
@@ -3,7 +3,7 @@
3
3
  module SourceMonitor
4
4
  class BulkScrapeEnablementsController < ApplicationController
5
5
  def create
6
- source_ids = enablement_params[:source_ids]
6
+ source_ids = resolve_source_ids
7
7
 
8
8
  if source_ids.empty?
9
9
  handle_empty_selection
@@ -31,10 +31,13 @@ module SourceMonitor
31
31
 
32
32
  private
33
33
 
34
- def enablement_params
35
- raw_ids = Array(params.dig(:bulk_scrape_enablement, :source_ids))
36
- ids = raw_ids.map(&:to_i).reject(&:zero?)
37
- { source_ids: ids }
34
+ def resolve_source_ids
35
+ if params.dig(:bulk_scrape_enablement, :select_all_pages) == "true"
36
+ Source.scrape_candidates.pluck(:id)
37
+ else
38
+ raw_ids = Array(params.dig(:bulk_scrape_enablement, :source_ids))
39
+ raw_ids.map(&:to_i).reject(&:zero?)
40
+ end
38
41
  end
39
42
 
40
43
  def handle_empty_selection
@@ -90,7 +90,7 @@ module SourceMonitor
90
90
  end
91
91
 
92
92
  def health_check_complete?(entry)
93
- %w[healthy unhealthy].include?(entry[:health_status].to_s)
93
+ %w[working failing].include?(entry[:health_status].to_s)
94
94
  end
95
95
 
96
96
  def health_check_targets
@@ -53,6 +53,7 @@ module SourceMonitor
53
53
  @avg_scraped_word_counts = word_counts[:scraped]
54
54
 
55
55
  @scrape_candidate_ids = compute_scrape_candidate_ids
56
+ @total_scrape_candidate_count = Source.scrape_candidates.count
56
57
 
57
58
  # Row partial preload requirements (V3): item_activity_rates,
58
59
  # avg_feed_word_counts, avg_scraped_word_counts are pre-computed above
@@ -24,7 +24,7 @@
24
24
  <span class="inline-block h-2 w-2 rounded-full bg-green-500" aria-hidden="true"></span>
25
25
  Working
26
26
  </span>
27
- <% when "unhealthy" %>
27
+ <% when "failing" %>
28
28
  <div class="space-y-1">
29
29
  <span class="inline-flex items-center gap-2 rounded-full bg-rose-100 px-2 py-1 text-xs font-semibold text-rose-800">
30
30
  <span class="inline-block h-2 w-2 rounded-full bg-rose-500" aria-hidden="true"></span>
@@ -1,29 +1,27 @@
1
- <div data-controller="modal" class="relative">
2
- <div data-modal-target="panel" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" data-action="click->modal#backdrop" role="dialog" aria-modal="true" aria-labelledby="bulk-scrape-enable-modal-heading">
3
- <div class="w-full max-w-md rounded-lg bg-white shadow-xl" data-action="click->modal#stop">
4
- <div class="border-b border-slate-200 px-6 py-4">
5
- <h3 id="bulk-scrape-enable-modal-heading" class="text-lg font-semibold text-slate-900">Enable Scraping</h3>
6
- </div>
7
- <div class="px-6 py-4">
8
- <p class="text-sm text-slate-700">
9
- This will enable scraping for the selected sources using the default scraper adapter.
10
- Each source's items will be scraped on their next scheduled run.
11
- </p>
12
- <p class="mt-3 text-sm font-medium text-amber-700">
13
- This action will modify the selected sources' configuration.
14
- </p>
15
- </div>
16
- <div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
17
- <button type="button"
18
- data-action="modal#close"
19
- class="rounded-md border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
20
- Cancel
21
- </button>
22
- <button type="submit"
23
- class="rounded-md bg-violet-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-violet-500">
24
- Confirm Enable
25
- </button>
26
- </div>
1
+ <div data-modal-target="panel" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" data-action="click->modal#backdrop" role="dialog" aria-modal="true" aria-labelledby="bulk-scrape-enable-modal-heading">
2
+ <div class="w-full max-w-md rounded-lg bg-white shadow-xl" data-action="click->modal#stop">
3
+ <div class="border-b border-slate-200 px-6 py-4">
4
+ <h3 id="bulk-scrape-enable-modal-heading" class="text-lg font-semibold text-slate-900">Enable Scraping</h3>
5
+ </div>
6
+ <div class="px-6 py-4">
7
+ <p class="text-sm text-slate-700">
8
+ This will enable scraping for the selected sources using the default scraper adapter.
9
+ Each source's items will be scraped on their next scheduled run.
10
+ </p>
11
+ <p class="mt-3 text-sm font-medium text-amber-700">
12
+ This action will modify the selected sources' configuration.
13
+ </p>
14
+ </div>
15
+ <div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
16
+ <button type="button"
17
+ data-action="modal#close"
18
+ class="rounded-md border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
19
+ Cancel
20
+ </button>
21
+ <button type="submit"
22
+ class="rounded-md bg-violet-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-violet-500">
23
+ Confirm Enable
24
+ </button>
27
25
  </div>
28
26
  </div>
29
27
  </div>
@@ -93,7 +93,7 @@
93
93
  <% end %>
94
94
  </div>
95
95
  <% end %>
96
- <%= form_with url: source_monitor.bulk_scrape_enablements_path, data: { controller: "select-all" } do |form| %>
96
+ <%= form_with url: source_monitor.bulk_scrape_enablements_path, data: { controller: "select-all modal", "select-all-total-candidates-value": @total_scrape_candidate_count } do |form| %>
97
97
  <table class="min-w-full divide-y divide-slate-200 text-left text-sm">
98
98
  <thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
99
99
  <tr>
@@ -241,6 +241,25 @@
241
241
  </tbody>
242
242
  </table>
243
243
 
244
+ <input type="hidden" name="bulk_scrape_enablement[select_all_pages]" value="true" disabled data-select-all-target="selectAllPagesInput">
245
+
246
+ <% if @total_scrape_candidate_count > @scrape_candidate_ids.size %>
247
+ <div data-select-all-target="crossPageBanner" data-selected="false" class="hidden border-t border-violet-100 bg-violet-50 px-4 py-2 text-center text-sm text-violet-700">
248
+ <span data-role="select">
249
+ All <%= @scrape_candidate_ids.size %> candidates on this page are selected.
250
+ <button type="button" data-action="select-all#selectAllPages" class="font-semibold text-violet-900 underline hover:text-violet-600">
251
+ Select all <%= @total_scrape_candidate_count %> candidates across all pages
252
+ </button>
253
+ </span>
254
+ <span data-role="deselect" class="hidden">
255
+ All <%= @total_scrape_candidate_count %> candidates across all pages are selected.
256
+ <button type="button" data-action="select-all#deselectAllPages" class="font-semibold text-violet-900 underline hover:text-violet-600">
257
+ Clear selection
258
+ </button>
259
+ </span>
260
+ </div>
261
+ <% end %>
262
+
244
263
  <div data-select-all-target="actionBar" class="hidden sticky bottom-0 border-t border-slate-200 bg-white px-4 py-3 shadow-md">
245
264
  <div class="flex items-center justify-between">
246
265
  <span class="text-sm text-slate-700">
data/docs/setup.md CHANGED
@@ -18,8 +18,8 @@ This guide consolidates the new guided installer, verification commands, and rol
18
18
  Run these commands inside your host Rails application before invoking the guided workflow:
19
19
 
20
20
  ```bash
21
- bundle add source_monitor --version "~> 0.12.0"
22
- # or add gem "source_monitor", "~> 0.12.0" to Gemfile manually
21
+ bundle add source_monitor --version "~> 0.12.3"
22
+ # or add gem "source_monitor", "~> 0.12.3" to Gemfile manually
23
23
  bundle install
24
24
  ```
25
25
 
data/docs/upgrade.md CHANGED
@@ -69,6 +69,34 @@ bin/rails db:migrate
69
69
  - New ViewComponents and presenters are available for custom view integration but are not required by default templates.
70
70
  - `Item#restore!` is the symmetric counterpart to `soft_delete!` — it clears `deleted_at` and increments the source `items_count` counter cache.
71
71
 
72
+ ### Upgrading to 0.12.3
73
+
74
+ **What changed:**
75
+ - UI fixes: menu icon (gear -> ellipsis), modal controller scope, cross-page select-all for bulk scraping recommendations
76
+
77
+ **Upgrade steps:**
78
+ ```bash
79
+ bundle update source_monitor
80
+ ```
81
+
82
+ **Notes:**
83
+ - No breaking changes, migrations, or configuration changes required.
84
+ - Patch fix release.
85
+
86
+ ### Upgrading to 0.12.2
87
+
88
+ **What changed:**
89
+ - Bug fix: Health check status vocabulary aligned (`working`/`failing`) across all components so the OPML import progress counter updates correctly instead of staying stuck at 0/N.
90
+
91
+ **Upgrade steps:**
92
+ ```bash
93
+ bundle update source_monitor
94
+ ```
95
+
96
+ **Notes:**
97
+ - No breaking changes, migrations, or configuration changes required.
98
+ - This is a pure bug fix for OPML import progress reporting.
99
+
72
100
  ### Upgrading to 0.11.0
73
101
 
74
102
  **What changed:**
@@ -39,7 +39,7 @@ module SourceMonitor
39
39
  def progress_data
40
40
  entries = health_entries
41
41
  total = import_session.health_check_targets.size
42
- completed = entries.count { |entry| %w[healthy unhealthy].include?(entry[:health_status].to_s) }
42
+ completed = entries.count { |entry| %w[working failing].include?(entry[:health_status].to_s) }
43
43
 
44
44
  {
45
45
  completed: completed,
@@ -37,7 +37,7 @@ module SourceMonitor
37
37
  )
38
38
 
39
39
  selected_ids = Array(import_session.selected_source_ids).map(&:to_s)
40
- selected_ids -= [ entry_id.to_s ] if result.status == "unhealthy"
40
+ selected_ids -= [ entry_id.to_s ] if result.status == "failing"
41
41
 
42
42
  attrs = {
43
43
  parsed_sources: entries,
@@ -87,7 +87,7 @@ module SourceMonitor
87
87
  filtered = normalized.select { |entry| targets.include?(entry[:id]) }
88
88
  return nil if filtered.empty?
89
89
 
90
- completed = filtered.count { |entry| %w[healthy unhealthy].include?(entry[:health_status].to_s) }
90
+ completed = filtered.count { |entry| %w[working failing].include?(entry[:health_status].to_s) }
91
91
  completed >= filtered.size ? Time.current : nil
92
92
  end
93
93
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SourceMonitor
4
- VERSION = "0.12.1"
4
+ VERSION = "0.12.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: source_monitor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk