Module: SourceMonitor::ApplicationHelper

Includes:
HealthBadgeHelper, TableSortHelper
Defined in:
app/helpers/source_monitor/application_helper.rb

Constant Summary collapse

ITEM_SCRAPE_STATUS_LABELS =

Unified status badge helper for both fetch and scrape operations

{
  "pending" => "Pending",
  "processing" => "Processing",
  "success" => "Scraped",
  "failed" => "Failed",
  "partial" => "Partial",
  "disabled" => "Disabled",
  "idle" => "Not scraped"
}.freeze

Instance Method Summary collapse

Methods included from HealthBadgeHelper

#interactive_health_status?, #source_health_actions, #source_health_badge

Methods included from TableSortHelper

#table_sort_aria, #table_sort_arrow, #table_sort_direction, #table_sort_link

Instance Method Details

#async_status_badge(status, show_spinner: true) ⇒ Object

Maps asynchronous workflow states to badge styling/labels shared across the engine. Item scraping builds on these core states, reusing the same colors so the UI stays consistent across sources, items, and job dashboards.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'app/helpers/source_monitor/application_helper.rb', line 140

def async_status_badge(status, show_spinner: true)
  status_str = status.to_s

  label, classes, spinner = case status_str
  when "queued"
    [ "Queued", "bg-amber-100 text-amber-700", show_spinner ]
  when "pending"
    [ "Pending", "bg-amber-100 text-amber-700", show_spinner ]
  when "fetching", "processing"
    [ "Processing", "bg-blue-100 text-blue-700", show_spinner ]
  when "success"
    [ "Completed", "bg-green-100 text-green-700", false ]
  when "failed"
    [ "Failed", "bg-rose-100 text-rose-700", false ]
  when "partial"
    [ "Partial", "bg-amber-100 text-amber-700", false ]
  when "disabled"
    [ "Disabled", "bg-slate-200 text-slate-600", false ]
  when "idle"
    [ "Idle", "bg-slate-100 text-slate-600", false ]
  else
    [ "Ready", "bg-slate-100 text-slate-600", false ]
  end

  { label: label, classes: classes, show_spinner: spinner }
end

#compact_blank_hash(hash) ⇒ Object



38
39
40
41
42
43
44
45
46
# File 'app/helpers/source_monitor/application_helper.rb', line 38

def compact_blank_hash(hash)
  return {} if hash.blank?

  if hash.respond_to?(:compact_blank)
    hash.compact_blank
  else
    hash.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
  end
end

#domain_from_url(url) ⇒ Object

Extracts the domain from a URL, returning nil if parsing fails.



225
226
227
228
229
230
231
# File 'app/helpers/source_monitor/application_helper.rb', line 225

def domain_from_url(url)
  return nil if url.blank?

  URI.parse(url.to_s).host
rescue URI::InvalidURIError
  nil
end

Renders a clickable link that opens in a new tab with an external-link icon. Returns the label as plain text if the URL is blank.



215
216
217
218
219
220
221
222
# File 'app/helpers/source_monitor/application_helper.rb', line 215

def external_link_to(label, url, **options)
  return label if url.blank?

  css = options.delete(:class) || "text-blue-600 hover:text-blue-500"
  link_to(url, target: "_blank", rel: "noopener noreferrer", class: css, title: url, **options) do
    safe_join([ label, " ", external_link_icon ])
  end
end

#fetch_interval_bucket_path(bucket, search_params, selected: false) ⇒ Object



48
49
50
51
52
53
# File 'app/helpers/source_monitor/application_helper.rb', line 48

def fetch_interval_bucket_path(bucket, search_params, selected: false)
  query = fetch_interval_bucket_query(bucket, search_params, selected: selected)
  route_helpers = SourceMonitor::Engine.routes.url_helpers

  query.empty? ? route_helpers.sources_path : route_helpers.sources_path(q: query)
end

#fetch_interval_bucket_query(bucket, search_params, selected: false) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'app/helpers/source_monitor/application_helper.rb', line 55

def fetch_interval_bucket_query(bucket, search_params, selected: false)
  base = (search_params || {}).dup
  base = base.except("fetch_interval_minutes_gteq", "fetch_interval_minutes_lt", "fetch_interval_minutes_lteq")

  query = if selected
    base
  else
    updated = base.dup
    updated["fetch_interval_minutes_gteq"] = bucket.min.to_i.to_s if bucket.respond_to?(:min) && bucket.min

    if bucket.respond_to?(:max) && bucket.max
      updated["fetch_interval_minutes_lt"] = bucket.max.to_i.to_s
    else
      updated.delete("fetch_interval_minutes_lt")
      updated.delete("fetch_interval_minutes_lteq")
    end

    updated
  end

  compact_blank_hash(query)
end

#fetch_interval_filter_label(bucket, filter) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/helpers/source_monitor/application_helper.rb', line 78

def fetch_interval_filter_label(bucket, filter)
  return bucket.label if bucket&.respond_to?(:label)
  return unless filter

  min = filter[:min]
  max = filter[:max]

  if min && max
    "#{min}-#{max} min"
  elsif min
    "#{min}+ min"
  else
    "Any interval"
  end
end

#fetch_schedule_window_label(group) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'app/helpers/source_monitor/application_helper.rb', line 94

def fetch_schedule_window_label(group)
  start_time = group.respond_to?(:window_start) ? group.window_start : nil
  end_time = group.respond_to?(:window_end) ? group.window_end : nil

  return unless start_time || end_time

  if start_time && end_time
    "#{format_schedule_time(start_time)}#{format_schedule_time(end_time)}"
  elsif start_time
    "After #{format_schedule_time(start_time)}"
  else
    nil
  end
end

#fetch_status_badge_classes(status) ⇒ Object

Legacy helper for backwards compatibility



186
187
188
# File 'app/helpers/source_monitor/application_helper.rb', line 186

def fetch_status_badge_classes(status)
  async_status_badge(status)
end

#format_schedule_time(time) ⇒ Object



109
110
111
112
113
# File 'app/helpers/source_monitor/application_helper.rb', line 109

def format_schedule_time(time)
  return unless time

  l(time.in_time_zone, format: :short)
end

#formatted_setting_value(value) ⇒ Object



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/helpers/source_monitor/application_helper.rb', line 196

def formatted_setting_value(value)
  case value
  when TrueClass
    "Enabled"
  when FalseClass
    "Disabled"
  when Hash
    value.to_json
  when Array
    value.join(", ")
  when NilClass
    ""
  else
    value
  end
end

#heatmap_bucket_classes(count, max_count) ⇒ Object



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'app/helpers/source_monitor/application_helper.rb', line 21

def heatmap_bucket_classes(count, max_count)
  return "bg-slate-100 text-slate-500" if max_count.to_i.zero? || count.to_i.zero?

  ratio = count.to_f / max_count

  case ratio
  when 0...0.25
    "bg-blue-100 text-blue-800"
  when 0.25...0.5
    "bg-blue-200 text-blue-900"
  when 0.5...0.75
    "bg-blue-400 text-white"
  else
    "bg-blue-600 text-white"
  end
end

#human_fetch_interval(minutes) ⇒ Object



115
116
117
118
119
120
121
122
123
124
# File 'app/helpers/source_monitor/application_helper.rb', line 115

def human_fetch_interval(minutes)
  return "" if minutes.blank?

  total_minutes = minutes.to_i
  hours, remaining = total_minutes.divmod(60)
  parts = []
  parts << "#{hours}h" if hours.positive?
  parts << "#{remaining}m" if remaining.positive? || parts.empty?
  parts.join(" ")
end

#item_scrape_status_badge(item:, source: nil, show_spinner: true) ⇒ Object

Returns a normalized badge payload for the source show/item pages. The status derives from the item’s recorded scrape_status, falls back to the source configuration, and always lands inside the known status set: pending, processing, success, failed, partial, disabled, or idle.



171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'app/helpers/source_monitor/application_helper.rb', line 171

def item_scrape_status_badge(item:, source: nil, show_spinner: true)
  status = derive_item_scrape_status(item:, source: source)
  base_badge = async_status_badge(status, show_spinner: show_spinner)
  label = ITEM_SCRAPE_STATUS_LABELS.fetch(status) { base_badge[:label] }
  spinner = base_badge[:show_spinner] && %w[pending processing].include?(status)

  {
    status: status,
    label: label,
    classes: base_badge[:classes],
    show_spinner: spinner
  }
end

#loading_spinner_svg(css_class: "mr-1 h-4 w-4 animate-spin text-blue-500") ⇒ Object

Helper to render the loading spinner SVG via IconComponent. Accepts a custom css_class to override the default spinner styling.



192
193
194
# File 'app/helpers/source_monitor/application_helper.rb', line 192

def loading_spinner_svg(css_class: "mr-1 h-4 w-4 animate-spin text-blue-500")
  render SourceMonitor::IconComponent.new(:spinner, size: nil, css_class: css_class)
end

#pagination_page_numbers(current_page:, total_pages:, window: 2) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'app/helpers/source_monitor/application_helper.rb', line 250

def pagination_page_numbers(current_page:, total_pages:, window: 2)
  return [ 1 ] if total_pages <= 1

  # When total pages fit without gaps, show them all
  if total_pages <= (2 * window) + 3
    return (1..total_pages).to_a
  end

  pages = [ 1, total_pages ]
  ((current_page - window)..(current_page + window)).each do |p|
    pages << p if p >= 1 && p <= total_pages
  end
  pages = pages.uniq.sort

  result = []
  last = 0
  pages.each do |p|
    result << :gap if p > last + 1
    result << p
    last = p
  end
  result
end

#source_favicon_tag(source, size: 24, **options) ⇒ Object

Renders the source favicon as an <img> tag or a colored-circle initials placeholder when no favicon is attached. Handles the case where ActiveStorage is not loaded (host app without AS).

Options:

size: pixel dimension for width/height (default: 24)
class: additional CSS classes


240
241
242
243
244
245
246
247
248
# File 'app/helpers/source_monitor/application_helper.rb', line 240

def source_favicon_tag(source, size: 24, **options)
  css = options.delete(:class) || ""

  if favicon_attached?(source)
    favicon_image_tag(source, size: size, css: css)
  else
    favicon_placeholder_tag(source, size: size, css: css)
  end
end

#source_monitor_javascript_bundle_tagObject



14
15
16
17
18
19
# File 'app/helpers/source_monitor/application_helper.rb', line 14

def source_monitor_javascript_bundle_tag
  javascript_include_tag("source_monitor/application", "data-turbo-track": "reload", type: "module")
rescue StandardError => error
  log_source_monitor_asset_error(:javascript, error)
  nil
end

#source_monitor_stylesheet_bundle_tagObject



7
8
9
10
11
12
# File 'app/helpers/source_monitor/application_helper.rb', line 7

def source_monitor_stylesheet_bundle_tag
  stylesheet_link_tag("source_monitor/application", "data-turbo-track": "reload")
rescue StandardError => error
  log_source_monitor_asset_error(:stylesheet, error)
  nil
end