newshound 1.0.2 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c28e3edb5bdc1e1c6af7b0ec4ea8e171fc912670515c19a1bef53f0a2e78f2c5
4
- data.tar.gz: e15c37e225e9140e112fd250ac860e555fe02606d1a6dfed40094f435fa94600
3
+ metadata.gz: e1f84ab484771f1f20e2ef1e27877c0eec621e1a260cc7439a67ec0d2c750036
4
+ data.tar.gz: 4eb10cad58543c5b40897beedea39ce7ccfeb438303c8de36ae0577a2f2a2a3e
5
5
  SHA512:
6
- metadata.gz: f89d1b069823ba254f663caf2efe01d4667d560bfc47739b04a98f4653fd3f6f747998aa3594be7848a770bf5f26bbeb705dfa0873d937fbb53565f5310dd252
7
- data.tar.gz: ce5a04dfc26b9782da9a308299d9ccc04273aa3ceb260256b9deb951ddf2f9b25b771cbcbcab5d56f46d33b4155b17821d02ca9e55cb6fafd4772c12388f9425
6
+ metadata.gz: 0d867822e4cd529f0e9b2a2a624ff586da589ccac751cd14f0355ca5bd1bf1643f95ee4ac3ed357ae98a1ccaeb2b7ed437020d2a475507932ae2bf10c69ff596
7
+ data.tar.gz: e1c6f082a2067af2fc8261a0416bb45b53b1f03147976eb221734bd8cd734eeec220aaa76bad8e35e382264c113f22784a10b2f58476bd9b6630f6e5ec0a4c2a
data/CHANGELOG.md CHANGED
@@ -5,21 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.0.2] - 2026-04-03
8
+ ## [1.1.0] - 2026-08-04
9
9
 
10
10
  ### Added
11
11
 
12
- - Bugsink exception source adapter for HTTP-based error tracking (6d3e2a1)
13
- - exception_source_config option for passing connection details to adapters (6d3e2a1)
14
- - Minimize button for banner (c686227)
15
-
16
- ## [1.0.1] - 2026-03-17
12
+ - config.position option for pinning the banner to the bottom of the viewport (1350e11)
13
+ - Separate failing and expired job counts (a6957bd)
14
+ - Close button that clears the banner from the current page (b6d3330)
17
15
 
18
16
  ### Changed
19
17
 
20
- - The authorize_with initializer will generate with the method inside the configure block. (2d6ef48)
18
+ - Banner CSS uses flat per-element classes instead of nested selectors (a75c3cd)
19
+ - Banner state classes renamed to newshound-banner-collapsed and newshound-banner-minimized (7e95fac)
20
+ - The banner alerts on expired jobs only, not ones still retrying (a6957bd)
21
+ - failed_jobs_threshold is now expired_jobs_threshold, with an alias (a6957bd)
22
+
23
+ ### Fixed
24
+
25
+ - Minimizing no longer leaves an empty strip where the banner was (b6d3330)
26
+ - Resolved errors no longer appear in the banner (b80e8c9)
27
+ - Repeat occurrences of one error collapse to a single row (b80e8c9)
28
+ - Minimizing no longer hides the banner from genuinely new problems (57dbdf5)
29
+
30
+ ## [1.0.3] - 2026-04-21
21
31
 
22
32
  ### Added
23
33
 
24
- - Configurable banner links for exceptions, jobs, and warnings via string path patterns (1df1636)
25
- - Record ID in format_for_banner output for linking to individual records (e06d040)
34
+ - failed_jobs_threshold configuration option (0caac13)
35
+
36
+ ### Changed
37
+
38
+ - Skip banner injection when there is nothing notable to display (0caac13)
data/README.md CHANGED
@@ -4,11 +4,11 @@ A Ruby gem that displays real-time exceptions and job statuses in a collapsible
4
4
 
5
5
  ## Features
6
6
 
7
- - 🎯 **Real-time Web UI Banner** - Shows exceptions and job statuses at the top of every page
7
+ - 🎯 **Real-time Web UI Banner** - Shows exceptions and job statuses at the top or bottom of every page
8
8
  - 🔐 **Role-Based Access** - Only visible to authorized users (developers, admins, etc.)
9
9
  - 📊 **Que Job Monitoring** - Real-time queue health and job status
10
10
  - 🚨 **Exception Tracking** - Recent exceptions from exception-track
11
- - 🎨 **Collapsible UI** - Clean, non-intrusive banner that expands on click
11
+ - 🎨 **Collapsible UI** - Expand, minimize to a corner pill, or close it out of the way
12
12
  - ⚡ **Zero Configuration** - Automatically injects into HTML responses
13
13
  - 🔧 **Highly Customizable** - Configure roles and authorization logic
14
14
 
@@ -42,6 +42,9 @@ Newshound.configure do |config|
42
42
  # Maximum number of exceptions to show in banner
43
43
  config.exception_limit = 10
44
44
 
45
+ # Which viewport edge the banner attaches to (:top or :bottom)
46
+ config.position = :top
47
+
45
48
  # User roles that can view the banner
46
49
  config.authorized_roles = [:developer, :super_user]
47
50
 
@@ -50,6 +53,77 @@ Newshound.configure do |config|
50
53
  end
51
54
  ```
52
55
 
56
+ ### Exception Sources
57
+
58
+ `config.exception_source` picks where exceptions come from, and `config.exception_source_config` passes options to that source.
59
+
60
+ | Source | Reads from | Options |
61
+ | --- | --- | --- |
62
+ | `:exception_track` (default) | the `exception-track` gem | none |
63
+ | `:solid_errors` | the `solid_errors` gem | `unresolved_only` |
64
+ | `:bugsink` | the Bugsink API | `url`, `token`, `project_id` (all required) |
65
+
66
+ ```ruby
67
+ Newshound.configure do |config|
68
+ config.exception_source = :solid_errors
69
+ config.exception_source_config = {unresolved_only: true}
70
+ end
71
+ ```
72
+
73
+ #### Solid Errors and `unresolved_only`
74
+
75
+ **`unresolved_only` defaults to `true`, which changes what the banner shows for apps upgrading from 1.0.x.** Older versions listed `SolidErrors::Occurrence` rows, which carry no resolved state, so resolving an error in your `/errors` UI left the banner lit.
76
+
77
+ With the default, the banner lists one row per unresolved `SolidErrors::Error`:
78
+
79
+ - Resolving an error removes it from the banner.
80
+ - An error that happened 300 times takes one banner slot instead of 300.
81
+ - The location slot shows the occurrence count — "300 occurrences" — rather than `controller#action`, because request context is recorded per occurrence and an error row has none.
82
+
83
+ Banner links still point at the error, so `config.exception_links = {show: "/errors/:id"}` resolves the same way in both modes.
84
+
85
+ Set `unresolved_only: false` to go back to listing individual occurrences.
86
+
87
+ ### Banner Position
88
+
89
+ By default the banner is fixed to the top of the viewport, and the page body gets matching top padding so the banner never covers content.
90
+
91
+ Set `config.position = :bottom` to move it to the bottom edge. A bottom banner floats over the page and reserves no space at all — nothing shifts, and the panel expands upward when you click the header. It does overlap whatever sits at the very bottom of the page, which is the tradeoff for leaving layout untouched.
92
+
93
+ Bottom placement suits apps with their own fixed header or sticky navigation, where a top banner would overlay the nav or throw off scroll-position calculations.
94
+
95
+ ```ruby
96
+ Newshound.configure do |config|
97
+ config.position = :bottom
98
+ end
99
+ ```
100
+
101
+ Any value other than `:top` or `:bottom` raises an `ArgumentError`.
102
+
103
+ ### Job Statistics
104
+
105
+ Que tracks two distinct trouble states, and Newshound reports them separately:
106
+
107
+ - **Failing** — the job errored but still has retries left. These usually clear themselves and are not worth an alert.
108
+ - **Expired** — the job ran out of retries. Nothing will run it again until a person does something.
109
+
110
+ Only expired jobs trigger the banner. `config.expired_jobs_threshold` sets how many are tolerated before it appears:
111
+
112
+ ```ruby
113
+ Newshound.configure do |config|
114
+ # Show the banner as soon as a single job expires
115
+ config.expired_jobs_threshold = 0
116
+ end
117
+ ```
118
+
119
+ The default is 0. Since an expired job is already dead, raising the threshold means hiding jobs that need attention — raise it only if you have a backlog you have deliberately decided to ignore.
120
+
121
+ `failed_jobs_threshold` is the old name for this setting and still works. It used to gate every errored job, retrying ones included, so a value chosen to absorb transient retries is now suppressing that many genuinely dead jobs. Drop it to 0 when you rename it.
122
+
123
+ `queue_statistics` and `job_counts_by_type` also still return a `:failed` key, the union of failing and expired. Its value is unchanged from earlier versions, but it is deprecated — read `:failing` and `:expired` instead.
124
+
125
+ Job links follow the same split. `job_links[:failing]` and `job_links[:expired]` point the two stats at your dashboard; `job_links[:failed]` is accepted as the old name for `:failing`.
126
+
53
127
  ### Advanced: Custom Authorization
54
128
 
55
129
  If the default role-based authorization doesn't fit your needs, you can provide custom logic:
@@ -92,27 +166,44 @@ Newshound uses Rails middleware to automatically inject a banner into HTML respo
92
166
  1. ✅ Appears automatically on all HTML pages
93
167
  2. 🔒 Only visible to users with authorized roles
94
168
  3. 📊 Shows real-time data from your exception and job queues
95
- 4. 🎨 Collapses to save space, expands on click
96
- 5. 🚀 No JavaScript dependencies, pure CSS animations
169
+ 4. 🎨 Collapses, minimizes, or closes out of the way (see [Banner Controls](#banner-controls))
170
+ 5. 🚀 No build step and no framework — it ships its own CSS and JS inline
97
171
 
98
172
  ## Banner Content
99
173
 
100
174
  The banner displays:
101
175
 
102
176
  ### Exception Section
103
- - Recent exceptions from exception-track
177
+ - Recent exceptions from the configured exception source
104
178
  - Exception class and message
105
- - Controller/action where it occurred
179
+ - Controller/action where it occurred, or the occurrence count (see [Exception Sources](#exception-sources))
106
180
  - Timestamp
107
181
  - Visual indicators (🟢 all clear / 🔴 errors)
108
182
 
109
183
  ### Job Queue Section
110
184
  - **Ready to Run**: Jobs waiting to execute
111
185
  - **Scheduled**: Jobs scheduled for future execution
112
- - **Failed**: Jobs in retry queue
186
+ - **Failing**: Jobs that errored and will retry
187
+ - **Expired**: Jobs that ran out of retries
113
188
  - **Completed Today**: Successfully finished jobs
114
189
  - Color-coded health status
115
190
 
191
+ ## Banner Controls
192
+
193
+ The banner header carries three buttons, all keyboard accessible:
194
+
195
+ | Control | Does |
196
+ | --- | --- |
197
+ | ▼ Toggle details | Expands or collapses the exception and job sections. Clicking anywhere on the header does the same. |
198
+ | − Minimize | Shrinks the banner to a 🐕 pill in the corner. Click the pill to bring the banner back. |
199
+ | × Close | Removes the banner from the current page so you can reach whatever it was covering. |
200
+
201
+ Closing is a per-page escape hatch, not an opt-out: the next page shows the banner as the minimized pill, so you still get the signal. Minimize and close both persist to `localStorage` under `newshound-minimized`, and restoring from the pill clears it.
202
+
203
+ Minimizing is not permanent either. Newshound stores a signature of what you were looking at when you minimized — the exception count, the newest exception id, the same two for warnings, and the failed job count — under `newshound-signature`. When any of those numbers is higher on a later page, something has gone wrong that you have not seen, so the banner comes back as the thin bar and the stored flag is dropped. Numbers that only fall — someone worked through the backlog — are not news, and the pill stays put.
204
+
205
+ There is no setting to make minimize stick. A banner you can silence forever is one that eventually leaves you blind, which is the failure this whole thing exists to prevent. To turn the banner off entirely, set `config.enabled = false` or narrow `authorized_roles`.
206
+
116
207
  ## User Requirements
117
208
 
118
209
  Your User model should have a `role` attribute that matches one of the configured `authorized_roles`. Common patterns:
@@ -211,7 +302,13 @@ This gem uses [Reissue](https://github.com/SOFware/reissue) for release manageme
211
302
  - **que** >= 1.0 (for job monitoring)
212
303
  - **exception-track** >= 0.1 (for exception tracking)
213
304
 
214
- ## Upgrading from 0.1.x
305
+ ## Upgrading
306
+
307
+ ### Resolved errors now drop out of the banner
308
+
309
+ Apps on `config.exception_source = :solid_errors` get the new `unresolved_only` default of `true` with no config change, so the banner stops showing errors you have resolved and collapses repeat occurrences into one row. See [Solid Errors and `unresolved_only`](#solid-errors-and-unresolved_only) for what the banner looks like either way, and set `unresolved_only: false` to keep the old listing.
310
+
311
+ ### From 0.1.x
215
312
 
216
313
  If you were using the previous Slack-based version:
217
314
 
@@ -1,26 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Newshound.configure do |config|
4
- # Enable or disable Newshound completely
5
- # When enabled, the banner will be shown to authorized users
6
- # Default is true
4
+ # Enable or disable Newshound completely When enabled, the banner will be shown to
5
+ # authorized users Default is true
7
6
  config.enabled = true
8
7
 
9
- # Maximum number of exceptions to include in banner
10
- # Default is 10
8
+ # Maximum number of exceptions to include in banner Default is 10
11
9
  config.exception_limit = 10
12
10
 
13
11
  # Exception source to use for exceptions
14
12
  # Default is :exception_track
15
- config.exception_source = :exception_track # or :solid_errors
13
+ config.exception_source = :exception_track # or :solid_errors, :bugsink
16
14
 
17
- # Job source adapter for monitoring background jobs
18
- # Uncomment and set to enable job monitoring in the banner
15
+ # Options passed to the exception source
16
+ # :solid_errors shows one row per unresolved error, so resolving an error in
17
+ # your /errors UI clears it from the banner Set unresolved_only: false to list
18
+ # individual occurrences instead
19
+ # config.exception_source_config = {unresolved_only: true}
20
+
21
+ # Job source adapter for monitoring background jobs Uncomment and set to enable job
22
+ # monitoring in the banner
19
23
  # config.job_source = :que # or a custom adapter instance
20
24
  # See Newshound::Jobs::Base for the adapter interface
21
25
 
22
- # User roles that are authorized to view the Newshound banner
23
- # These should match the role values in your User model
26
+ # Number of expired jobs at or below which the banner is suppressed
27
+ # Expired jobs are out of retries, so 0 is the recommended value
28
+ # Jobs that are still retrying never trigger the banner
29
+ # Default is 0 (any expired job triggers the banner)
30
+ # config.expired_jobs_threshold = 0
31
+
32
+ # Which viewport edge the banner attaches to, :top or :bottom
33
+ # Use :bottom to keep the banner clear of fixed headers and sticky navigation
34
+ # Default is :top
35
+ # config.position = :bottom
36
+
37
+ # User roles that are authorized to view the Newshound banner These should match
38
+ # the role values in your User model
24
39
  # Default is [:developer, :super_user]
25
40
  config.authorized_roles = [:developer, :super_user]
26
41
 
@@ -29,8 +44,8 @@ Newshound.configure do |config|
29
44
  # Default is :current_user
30
45
  config.current_user_method = :current_user
31
46
 
32
- # Links for banner items
33
- # Configure paths so banner items link to your exception/job dashboards.
47
+ # Links for banner items Configure paths so banner items link to your exception/job
48
+ # dashboards.
34
49
  # Use :id in show paths to interpolate the record ID.
35
50
  #
36
51
  # config.exception_links = {
@@ -42,7 +57,8 @@ Newshound.configure do |config|
42
57
  # index: "/background_jobs",
43
58
  # show: "/background_jobs/jobs/:id",
44
59
  # scheduled: "/background_jobs/scheduled",
45
- # failed: "/background_jobs/failed",
60
+ # failing: "/background_jobs/failing",
61
+ # expired: "/background_jobs/expired",
46
62
  # completed: "/background_jobs/completed"
47
63
  # }
48
64
  #
@@ -2,12 +2,22 @@
2
2
 
3
3
  module Newshound
4
4
  class Configuration
5
+ POSITIONS = %i[top bottom].freeze
6
+
5
7
  attr_accessor :exception_limit, :enabled, :authorized_roles,
6
8
  :current_user_method, :authorization_block, :exception_source,
7
9
  :exception_source_config,
8
10
  :warning_source, :warning_limit, :job_source,
11
+ :expired_jobs_threshold,
9
12
  :exception_links, :job_links, :warning_links
10
13
 
14
+ # The pre-1.0.4 name. It now gates expired jobs rather than every errored job,
15
+ # so a value tuned to absorb retries is worth revisiting.
16
+ alias_method :failed_jobs_threshold, :expired_jobs_threshold
17
+ alias_method :failed_jobs_threshold=, :expired_jobs_threshold=
18
+
19
+ attr_reader :position
20
+
11
21
  def initialize
12
22
  @exception_limit = 10
13
23
  @enabled = true
@@ -19,9 +29,21 @@ module Newshound
19
29
  @warning_source = nil
20
30
  @warning_limit = 10
21
31
  @job_source = nil
32
+ @expired_jobs_threshold = 0
22
33
  @exception_links = {}
23
34
  @job_links = {}
24
35
  @warning_links = {}
36
+ @position = :top
37
+ end
38
+
39
+ # Which viewport edge the banner attaches to, :top or :bottom
40
+ def position=(value)
41
+ unless POSITIONS.include?(value)
42
+ raise ArgumentError,
43
+ "Invalid position: #{value.inspect}. Must be :top or :bottom"
44
+ end
45
+
46
+ @position = value
25
47
  end
26
48
 
27
49
  # Allow custom authorization logic
@@ -1,7 +1,20 @@
1
1
  module Newshound
2
2
  module Exceptions
3
3
  class SolidErrors < Base
4
+ LAST_OCCURRED_AT = "MAX(solid_errors_occurrences.created_at) AS last_occurred_at"
5
+ OCCURRENCE_COUNT = "COUNT(solid_errors_occurrences.id) AS occurrence_count"
6
+
7
+ # Pass `unresolved_only: false` in exception_source_config to list raw
8
+ # occurrence rows instead of one row per unresolved error.
9
+ def initialize(config = {})
10
+ super
11
+ config = config.transform_keys(&:to_sym)
12
+ @unresolved_only = config.fetch(:unresolved_only, true)
13
+ end
14
+
4
15
  def recent(time_range:, limit:)
16
+ return unresolved_errors(time_range, limit) if @unresolved_only
17
+
5
18
  ::SolidErrors::Occurrence
6
19
  .where("created_at >= ?", time_range.ago)
7
20
  .order(created_at: :desc)
@@ -13,8 +26,8 @@ module Newshound
13
26
 
14
27
  <<~TEXT
15
28
  *#{number}. #{details[:title]}*
16
- • *Time:* #{exception.created_at.strftime("%I:%M %p")}
17
- #{format_controller(details)}
29
+ • *Time:* #{details[:time]}
30
+ #{format_location(details)}
18
31
  #{format_message(details)}
19
32
  TEXT
20
33
  end
@@ -23,32 +36,80 @@ module Newshound
23
36
  details = parse_exception_details(exception)
24
37
 
25
38
  {
26
- id: exception.try(:error)&.id || exception.try(:id),
39
+ id: banner_id(exception),
27
40
  title: details[:title],
28
41
  message: details[:message].truncate(100),
29
42
  location: details[:location],
30
- time: exception.created_at.strftime("%I:%M %p")
43
+ time: details[:time]
31
44
  }
32
45
  end
33
46
 
34
47
  private
35
48
 
49
+ # Apps build /errors/:id links from this, so both modes report the error id.
50
+ def banner_id(exception)
51
+ return exception.id if @unresolved_only
52
+
53
+ exception.try(:error)&.id || exception.try(:id)
54
+ end
55
+
56
+ # Resolved state lives on the parent error, so listing errors rather than
57
+ # occurrences is what lets the /errors UI quiet the banner. Grouping also keeps
58
+ # one error hit 300 times from filling every banner slot.
59
+ def unresolved_errors(time_range, limit)
60
+ ::SolidErrors::Error
61
+ .unresolved
62
+ .joins(:occurrences)
63
+ .where(solid_errors_occurrences: {created_at: time_range.ago..})
64
+ .group(:id)
65
+ .select("solid_errors.*", LAST_OCCURRED_AT, OCCURRENCE_COUNT)
66
+ .order("last_occurred_at DESC")
67
+ .limit(limit)
68
+ end
69
+
36
70
  def parse_exception_details(exception)
71
+ @unresolved_only ? error_details(exception) : occurrence_details(exception)
72
+ end
73
+
74
+ # Request context is recorded per occurrence, so an error row has none to
75
+ # offer the location slot. The occurrence count is what grouping produces,
76
+ # and it is already selected.
77
+ def error_details(exception)
78
+ {
79
+ title: exception.exception_class.presence || "Unknown Exception",
80
+ message: exception.message.presence.to_s,
81
+ location: occurrence_summary(exception),
82
+ location_label: "Occurrences",
83
+ time: format_time(exception.last_occurred_at)
84
+ }
85
+ end
86
+
87
+ def occurrence_details(exception)
37
88
  context_data = parse_context(exception)
38
89
  controller = context_data["controller"]
39
90
  action = context_data["action"]
40
-
41
91
  error_record = exception.try(:error)
92
+ message = error_record&.message.presence || context_data["message"].presence
42
93
 
43
94
  {
44
95
  title: error_record&.exception_class.presence || "Unknown Exception",
45
- message: error_record&.message.presence&.to_s || context_data["message"].presence&.to_s || +"",
96
+ message: message.to_s,
46
97
  location: (controller && action) ? "#{controller}##{action}" : +"",
47
- controller: controller,
48
- action: action
98
+ location_label: "Controller",
99
+ time: format_time(exception.created_at)
49
100
  }
50
101
  end
51
102
 
103
+ def occurrence_summary(exception)
104
+ count = exception.occurrence_count.to_i
105
+ "#{count} #{(count == 1) ? "occurrence" : "occurrences"}"
106
+ end
107
+
108
+ # MAX() comes back as a String on some adapters.
109
+ def format_time(time)
110
+ time.in_time_zone.strftime("%I:%M %p")
111
+ end
112
+
52
113
  def parse_context(exception)
53
114
  return {} unless exception.respond_to?(:context) && exception.context.present?
54
115
 
@@ -64,10 +125,10 @@ module Newshound
64
125
  {}
65
126
  end
66
127
 
67
- def format_controller(details)
68
- return +"" unless details in {controller: String, action: String}
128
+ def format_location(details)
129
+ return +"" if details[:location].blank?
69
130
 
70
- "• *Controller:* #{details[:location]}\n"
131
+ "• *#{details[:location_label]}:* #{details[:location]}\n"
71
132
  end
72
133
 
73
134
  def format_message(details)
@@ -60,13 +60,20 @@ module Newshound
60
60
  lines = ["*Job Counts by Type:*"]
61
61
 
62
62
  counts.each do |job_class, stats|
63
- status_emoji = (stats[:failed] > 0) ? "⚠️" : "✅"
64
- lines << "• #{status_emoji} *#{job_class}*: #{stats[:total]} total (#{stats[:success]} success, #{stats[:failed]} failed)"
63
+ breakdown = "#{stats[:success]} success, #{stats[:failing].to_i} failing, #{stats[:expired].to_i} expired"
64
+ lines << "• #{job_status_emoji(stats)} *#{job_class}*: #{stats[:total]} total (#{breakdown})"
65
65
  end
66
66
 
67
67
  lines.join("\n")
68
68
  end
69
69
 
70
+ def job_status_emoji(stats)
71
+ return "⚠️" if stats[:expired].to_i > 0
72
+ return "🟡" if stats[:failing].to_i > 0
73
+
74
+ "✅"
75
+ end
76
+
70
77
  def queue_health_section
71
78
  stats = job_source.queue_statistics
72
79
 
@@ -80,17 +87,21 @@ module Newshound
80
87
  end
81
88
 
82
89
  def format_queue_health(stats)
83
- health_emoji = if stats[:failed] > 10
90
+ failing = stats[:failing].to_i
91
+ expired = stats[:expired].to_i
92
+
93
+ health_emoji = if expired > 0
84
94
  "🔴"
85
95
  else
86
- (stats[:failed] > 5) ? "🟡" : "🟢"
96
+ (failing > 0) ? "🟡" : "🟢"
87
97
  end
88
98
 
89
99
  <<~TEXT
90
100
  *Queue Health #{health_emoji}*
91
101
  • *Ready to Run:* #{stats[:ready]}
92
102
  • *Scheduled:* #{stats[:scheduled]}
93
- • *Failed (Retry Queue):* #{stats[:failed]}
103
+ • *Failing (Will Retry):* #{failing}
104
+ • *Expired (Out of Retries):* #{expired}
94
105
  • *Completed Today:* #{stats[:finished_today]}
95
106
  TEXT
96
107
  end
@@ -18,21 +18,25 @@ module Newshound
18
18
  class Base
19
19
  # Returns queue-level statistics
20
20
  #
21
- # @return [Hash] with keys :ready, :scheduled, :failed, :finished_today
21
+ # @return [Hash] with keys :ready, :scheduled, :failing, :expired, :failed,
22
+ # :finished_today. :failing counts jobs that errored and will retry,
23
+ # :expired counts jobs that are out of retries. :failed is the union and is
24
+ # deprecated.
22
25
  def queue_statistics
23
26
  raise NotImplementedError, "#{self.class} must implement #queue_statistics"
24
27
  end
25
28
 
26
29
  # Returns job counts grouped by job class
27
30
  #
28
- # @return [Hash] job_class => { success:, failed:, total: }
31
+ # @return [Hash] job_class => { success:, failing:, expired:, failed:, total: }
29
32
  def job_counts_by_type
30
33
  raise NotImplementedError, "#{self.class} must implement #job_counts_by_type"
31
34
  end
32
35
 
33
36
  # Returns data formatted for the banner UI
34
37
  #
35
- # @return [Hash] with key :queue_stats containing ready_to_run, scheduled, failed, completed_today
38
+ # @return [Hash] with key :queue_stats containing ready_to_run, scheduled,
39
+ # failing, expired, failed, completed_today
36
40
  def format_for_banner
37
41
  stats = queue_statistics
38
42
 
@@ -40,6 +44,9 @@ module Newshound
40
44
  queue_stats: {
41
45
  ready_to_run: stats[:ready],
42
46
  scheduled: stats[:scheduled],
47
+ failing: stats[:failing],
48
+ # Adapters written before the failing/expired split report only :failed.
49
+ expired: stats.fetch(:expired) { stats[:failed] },
43
50
  failed: stats[:failed],
44
51
  completed_today: stats[:finished_today]
45
52
  }
@@ -9,6 +9,9 @@ module Newshound
9
9
  @logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
10
10
  end
11
11
 
12
+ # Que separates two trouble states: a failing job has errored but still has
13
+ # retries left, an expired job has run out of them and needs a human. :failed
14
+ # is the union of the two, kept for callers written before the split.
12
15
  def queue_statistics
13
16
  conn = ActiveRecord::Base.connection
14
17
  current_time = conn.quote(Time.now)
@@ -17,37 +20,40 @@ module Newshound
17
20
  {
18
21
  ready: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at <= #{current_time}"),
19
22
  scheduled: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at > #{current_time}"),
23
+ failing: count_jobs("error_count > 0 AND finished_at IS NULL AND expired_at IS NULL"),
24
+ expired: count_jobs("expired_at IS NOT NULL"),
20
25
  failed: count_jobs("error_count > 0 AND finished_at IS NULL"),
21
26
  finished_today: count_jobs("finished_at >= #{beginning_of_day}")
22
27
  }
23
28
  rescue => e
24
29
  logger.error "Failed to fetch Que statistics: #{e.message}"
25
- {ready: 0, scheduled: 0, failed: 0, finished_today: 0}
30
+ {ready: 0, scheduled: 0, failing: 0, expired: 0, failed: 0, finished_today: 0}
26
31
  end
27
32
 
28
33
  def job_counts_by_type
29
34
  results = ActiveRecord::Base.connection.execute(<<~SQL)
30
- SELECT job_class, error_count, COUNT(*) as count
35
+ SELECT job_class,
36
+ COUNT(*) FILTER (WHERE error_count = 0 AND expired_at IS NULL) AS success,
37
+ COUNT(*) FILTER (WHERE error_count > 0 AND expired_at IS NULL) AS failing,
38
+ COUNT(*) FILTER (WHERE expired_at IS NOT NULL) AS expired,
39
+ COUNT(*) AS total
31
40
  FROM que_jobs
32
41
  WHERE finished_at IS NULL
33
- GROUP BY job_class, error_count
42
+ GROUP BY job_class
34
43
  ORDER BY job_class
35
44
  SQL
36
45
 
37
46
  results.each_with_object({}) do |row, hash|
38
- job_class = row["job_class"]
39
- error_count = row["error_count"].to_i
40
- count = row["count"].to_i
47
+ failing = row["failing"].to_i
48
+ expired = row["expired"].to_i
41
49
 
42
- hash[job_class] ||= {success: 0, failed: 0, total: 0}
43
-
44
- if error_count.zero?
45
- hash[job_class][:success] += count
46
- else
47
- hash[job_class][:failed] += count
48
- end
49
-
50
- hash[job_class][:total] += count
50
+ hash[row["job_class"]] = {
51
+ success: row["success"].to_i,
52
+ failing:,
53
+ expired:,
54
+ failed: failing + expired,
55
+ total: row["total"].to_i
56
+ }
51
57
  end
52
58
  rescue => e
53
59
  logger.error "Failed to fetch job counts: #{e.message}"
@@ -19,8 +19,9 @@ module Newshound
19
19
  return [status, headers, response] unless controller
20
20
  return [status, headers, response] unless Newshound::Authorization.authorized?(controller)
21
21
 
22
- # Get banner HTML
22
+ # Get banner HTML (nil when nothing notable to display)
23
23
  banner_html = generate_banner_html
24
+ return [status, headers, response] unless banner_html
24
25
 
25
26
  # Inject banner after <body> tag
26
27
  new_response = inject_banner(response, banner_html)
@@ -62,135 +63,394 @@ module Newshound
62
63
  job_data = job_reporter.banner_data
63
64
  warning_data = warning_reporter.banner_data
64
65
 
65
- # Generate HTML from template
66
+ return nil unless notable_data?(exception_data, job_data, warning_data)
67
+
66
68
  render_banner(exception_data, job_data, warning_data)
67
69
  end
68
70
 
71
+ # Only expired jobs are worth interrupting someone over. Failing jobs still
72
+ # have retries left and usually clear themselves.
73
+ def notable_data?(exception_data, job_data, warning_data)
74
+ exception_count = exception_data[:exceptions]&.length || 0
75
+ expired_jobs = job_data.dig(:queue_stats, :expired) || 0
76
+ warning_count = warning_data[:warnings]&.length || 0
77
+ threshold = Newshound.configuration.expired_jobs_threshold
78
+
79
+ exception_count > 0 || warning_count > 0 || expired_jobs > threshold
80
+ end
81
+
82
+ # The stylesheet is a sibling of the banner so closing can remove the banner
83
+ # without taking the `html body` padding rule down with it.
69
84
  def render_banner(exception_data, job_data, warning_data = {})
70
85
  <<~HTML
71
- <div id="newshound-banner" class="newshound-banner newshound-collapsed">
72
- #{render_styles}
73
- <div class="newshound-header" onclick="document.getElementById('newshound-banner').classList.toggle('newshound-collapsed'); window.newshoundUpdatePadding();">
86
+ #{render_styles}
87
+ <div id="newshound-banner" class="#{positioned_class(:banner)} newshound-banner-collapsed" data-newshound-signature="#{escape_html(banner_signature(exception_data, job_data, warning_data))}">
88
+ <div class="newshound-header" data-newshound-action="toggle">
74
89
  <span class="newshound-title">
75
90
  🐕 Newshound
76
91
  #{summary_badge(exception_data, job_data, warning_data)}
77
92
  </span>
78
93
  <span class="newshound-header-controls">
79
- <span class="newshound-minimize" onclick="event.stopPropagation(); document.getElementById('newshound-banner').classList.add('newshound-minimized'); localStorage.setItem('newshound-minimized','1'); window.newshoundUpdatePadding();" title="Minimize">−</span>
80
- <span class="newshound-toggle">▼</span>
94
+ #{render_header_controls}
81
95
  </span>
82
96
  </div>
83
- <div class="newshound-content">
97
+ <div class="#{positioned_class(:content)}">
84
98
  #{render_exceptions(exception_data)}
85
99
  #{render_warnings(warning_data)}
86
100
  #{render_jobs(job_data)}
87
101
  </div>
88
- <div class="newshound-restore" onclick="document.getElementById('newshound-banner').classList.remove('newshound-minimized'); localStorage.removeItem('newshound-minimized'); window.newshoundUpdatePadding();" title="Restore Newshound">
89
- 🐕
90
- </div>
102
+ #{render_restore_control}
91
103
  </div>
92
104
  #{render_script}
93
105
  HTML
94
106
  end
95
107
 
108
+ # Two sections, compared by different rules. The counts only rise when there is
109
+ # something unseen, so the script reads a rise as news and a fall as cleanup.
110
+ # The id lists catch one item arriving while another is cleared, which leaves
111
+ # the counts unchanged, and they work for sources whose ids are not numbers.
112
+ def banner_signature(exception_data, job_data, warning_data)
113
+ exceptions = exception_data[:exceptions] || []
114
+ warnings = warning_data[:warnings] || []
115
+
116
+ [
117
+ [exceptions.length, warnings.length, actionable_jobs(job_data)].join(","),
118
+ item_ids(exceptions),
119
+ item_ids(warnings)
120
+ ].join("|")
121
+ end
122
+
123
+ # Mirror what the banner itself treats as worth showing: jobs at or below the
124
+ # threshold never raise it, so a rise within that tolerance is not news either.
125
+ # Adapters predating the failing/expired split report :failed alone.
126
+ def actionable_jobs(job_data)
127
+ stats = job_data[:queue_stats] || {}
128
+ count = stats.fetch(:expired) { stats[:failed] } || 0
129
+
130
+ (count > Newshound.configuration.failed_jobs_threshold) ? count : 0
131
+ end
132
+
133
+ def item_ids(items)
134
+ items.filter_map { |item| item[:id] }.join(",")
135
+ end
136
+
137
+ def render_header_controls
138
+ <<~HTML
139
+ <button type="button" class="newshound-control newshound-toggle" data-newshound-action="toggle" aria-expanded="false" aria-label="Toggle Newshound details" title="Toggle details">▼</button>
140
+ <button type="button" class="newshound-control newshound-minimize" data-newshound-action="minimize" aria-label="Minimize Newshound" title="Minimize to a corner pill">−</button>
141
+ <button type="button" class="newshound-control newshound-close" data-newshound-action="close" aria-label="Close Newshound" title="Close (returns minimized on the next page)">×</button>
142
+ HTML
143
+ end
144
+
145
+ # The only way back once the banner is minimized, so it is always rendered and
146
+ # always focusable.
147
+ def render_restore_control
148
+ <<~HTML
149
+ <button type="button" class="newshound-control newshound-restore" data-newshound-action="restore" aria-label="Restore Newshound" title="Restore Newshound">🐕</button>
150
+ HTML
151
+ end
152
+
96
153
  def render_script
97
154
  <<~JS
98
155
  <script>
99
156
  (function() {
100
- // Restore minimized state from localStorage
101
- if (localStorage.getItem('newshound-minimized')) {
102
- document.getElementById('newshound-banner').classList.add('newshound-minimized');
157
+ function banner() {
158
+ return document.getElementById('newshound-banner');
103
159
  }
104
160
 
105
- var cachedPriority, cachedBodyRule;
161
+ // Read once: closing detaches the banner, and the flag is written
162
+ // after that.
163
+ var signature = banner().getAttribute('data-newshound-signature');
106
164
 
107
- // Detect once whether any non-newshound stylesheet uses !important on body padding-top
108
- function detectPriority() {
109
- var styles = document.querySelectorAll('style:not(#newshound-styles)');
110
-
111
- for (var i = 0; i < styles.length; i++) {
112
- if (styles[i].textContent.match(/body\\s*{[^}]*padding-top[^;]*!important/s)) {
113
- return 'important';
114
- }
115
- }
165
+ // localStorage throws in some private-browsing modes, and a throw here
166
+ // would leave a control half-applied.
167
+ function rememberMinimized() {
168
+ try {
169
+ localStorage.setItem('newshound-minimized', '1');
170
+ localStorage.setItem('newshound-signature', signature);
171
+ } catch (e) {}
172
+ }
116
173
 
174
+ function forgetMinimized() {
117
175
  try {
118
- for (var s = 0; s < document.styleSheets.length; s++) {
119
- var sheet = document.styleSheets[s];
120
- if (sheet.ownerNode && sheet.ownerNode.id === 'newshound-styles') continue;
121
- var rules = sheet.cssRules || [];
122
- for (var r = 0; r < rules.length; r++) {
123
- if (rules[r].selectorText === 'body' &&
124
- rules[r].style.getPropertyPriority('padding-top') === 'important') {
125
- return 'important';
126
- }
127
- }
128
- }
176
+ localStorage.removeItem('newshound-minimized');
177
+ localStorage.removeItem('newshound-signature');
129
178
  } catch (e) {}
179
+ }
130
180
 
131
- return '';
181
+ function wasMinimized() {
182
+ try {
183
+ return localStorage.getItem('newshound-minimized') === '1';
184
+ } catch (e) {
185
+ return false;
186
+ }
132
187
  }
133
188
 
134
- // Find the html body rule in the newshound stylesheet once
135
- function findBodyRule() {
136
- var styleEl = document.getElementById('newshound-styles');
137
- if (!styleEl || !styleEl.sheet) return null;
138
- var rules = styleEl.sheet.cssRules;
139
- for (var i = 0; i < rules.length; i++) {
140
- if (rules[i].selectorText === 'html body') return rules[i];
189
+ // Any number that has risen since the developer minimized the banner
190
+ // is a problem they have not seen yet. Anything else — a number that
191
+ // fell, or a signature this version cannot read — is not news.
192
+ function hasNewNews() {
193
+ var seen;
194
+ try { seen = localStorage.getItem('newshound-signature'); } catch (e) {}
195
+ if (!seen) return true;
196
+
197
+ var now = signature.split('|');
198
+ var before = seen.split('|');
199
+ if (now.length !== before.length) return true;
200
+
201
+ if (hasRisen(now[0], before[0])) return true;
202
+
203
+ for (var s = 1; s < now.length; s++) {
204
+ if (hasUnseenId(now[s], before[s])) return true;
141
205
  }
142
- return null;
206
+
207
+ return false;
143
208
  }
144
209
 
145
- window.newshoundUpdatePadding = function() {
146
- setTimeout(function() {
147
- var banner = document.getElementById('newshound-banner');
148
- if (!banner) return;
210
+ function hasRisen(counts, seenCounts) {
211
+ var now = counts.split(',');
212
+ var before = seenCounts.split(',');
213
+ if (now.length !== before.length) return true;
149
214
 
150
- if (cachedBodyRule === undefined) cachedBodyRule = findBodyRule();
151
- if (!cachedBodyRule) return;
215
+ for (var i = 0; i < now.length; i++) {
216
+ if (Number(now[i]) > Number(before[i])) return true;
217
+ }
218
+
219
+ return false;
220
+ }
152
221
 
153
- if (cachedPriority === undefined) cachedPriority = detectPriority();
222
+ // Clearing an item leaves every remaining id already seen, so only an
223
+ // arrival counts.
224
+ function hasUnseenId(current, seen) {
225
+ if (!current) return false;
154
226
 
155
- cachedBodyRule.style.setProperty('padding-top', banner.offsetHeight + 'px', cachedPriority);
156
- }, 300);
157
- };
227
+ var known = seen ? seen.split(',') : [];
228
+ var ids = current.split(',');
158
229
 
159
- if (document.readyState === 'loading') {
160
- document.addEventListener('DOMContentLoaded', window.newshoundUpdatePadding);
161
- } else {
162
- window.newshoundUpdatePadding();
230
+ for (var i = 0; i < ids.length; i++) {
231
+ if (known.indexOf(ids[i]) === -1) return true;
232
+ }
233
+
234
+ return false;
163
235
  }
164
236
 
165
- window.addEventListener('resize', window.newshoundUpdatePadding);
237
+ // Applied before the padding script below takes its first measurement.
238
+ // A minimize lasts only until the next problem the developer has not
239
+ // seen, so it cannot leave them blind.
240
+ if (wasMinimized()) {
241
+ if (hasNewNews()) {
242
+ forgetMinimized();
243
+ } else {
244
+ banner().classList.add('newshound-banner-minimized');
245
+ }
246
+ }
247
+
248
+ #{render_controls_script}
249
+
250
+ #{render_padding_script}
166
251
  })();
167
252
  </script>
168
253
  JS
169
254
  end
170
255
 
256
+ def render_controls_script
257
+ <<~JS
258
+ var actions = {
259
+ toggle: function(el) {
260
+ var expanded = !el.classList.toggle('newshound-banner-collapsed');
261
+ var toggle = el.querySelector('.newshound-toggle');
262
+ if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
263
+ },
264
+ // Minimize and restore each hide the control the keyboard is sitting on,
265
+ // so each hands focus to the one that replaces it.
266
+ minimize: function(el) {
267
+ el.classList.add('newshound-banner-minimized');
268
+ rememberMinimized();
269
+ focusControl(el, '.newshound-restore');
270
+ },
271
+ restore: function(el) {
272
+ el.classList.remove('newshound-banner-minimized');
273
+ forgetMinimized();
274
+ focusControl(el, '.newshound-minimize');
275
+ },
276
+ // Closing clears the current page; the flag brings the banner back as a
277
+ // pill on the next load rather than suppressing it for good.
278
+ close: function(el) {
279
+ el.remove();
280
+ rememberMinimized();
281
+ }
282
+ };
283
+
284
+ function focusControl(el, selector) {
285
+ var control = el.querySelector(selector);
286
+ if (control) control.focus();
287
+ }
288
+
289
+ // Scoped to the banner so a second injection cannot stack listeners that
290
+ // outlive it.
291
+ banner().addEventListener('click', function(event) {
292
+ if (!event.target.closest) return;
293
+
294
+ var trigger = event.target.closest('[data-newshound-action]');
295
+ if (!trigger) return;
296
+
297
+ var action = actions[trigger.getAttribute('data-newshound-action')];
298
+ var el = banner();
299
+ if (!action || !el) return;
300
+
301
+ action(el);
302
+ if (window.newshoundUpdatePadding) window.newshoundUpdatePadding();
303
+ });
304
+ JS
305
+ end
306
+
307
+ # A bottom banner floats over the page, so there is no space to reserve. The
308
+ # delegated click handler still calls this, so it has to exist either way.
309
+ def render_padding_script
310
+ return "window.newshoundUpdatePadding = function() {};" if bottom?
311
+
312
+ <<~JS
313
+ var cachedPriority, cachedBodyRule;
314
+
315
+ // Detect once whether any non-newshound stylesheet uses !important on the body padding we set
316
+ function detectPriority() {
317
+ var styles = document.querySelectorAll('style:not(#newshound-styles)');
318
+
319
+ for (var i = 0; i < styles.length; i++) {
320
+ if (styles[i].textContent.match(/body\\s*{[^}]*padding-top[^;]*!important/s)) {
321
+ return 'important';
322
+ }
323
+ }
324
+
325
+ try {
326
+ for (var s = 0; s < document.styleSheets.length; s++) {
327
+ var sheet = document.styleSheets[s];
328
+ if (sheet.ownerNode && sheet.ownerNode.id === 'newshound-styles') continue;
329
+ var rules = sheet.cssRules || [];
330
+ for (var r = 0; r < rules.length; r++) {
331
+ if (rules[r].selectorText === 'body' &&
332
+ rules[r].style.getPropertyPriority('padding-top') === 'important') {
333
+ return 'important';
334
+ }
335
+ }
336
+ }
337
+ } catch (e) {}
338
+
339
+ return '';
340
+ }
341
+
342
+ // Find the html body rule in the newshound stylesheet once
343
+ function findBodyRule() {
344
+ var styleEl = document.getElementById('newshound-styles');
345
+ if (!styleEl || !styleEl.sheet) return null;
346
+ var rules = styleEl.sheet.cssRules;
347
+ for (var i = 0; i < rules.length; i++) {
348
+ if (rules[i].selectorText === 'html body') return rules[i];
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function applyPadding(offset) {
354
+ if (cachedBodyRule === undefined) cachedBodyRule = findBodyRule();
355
+ if (!cachedBodyRule) return;
356
+
357
+ if (cachedPriority === undefined) cachedPriority = detectPriority();
358
+
359
+ cachedBodyRule.style.setProperty('padding-top', offset + 'px', cachedPriority);
360
+ }
361
+
362
+ window.newshoundUpdatePadding = function() {
363
+ var el = banner();
364
+
365
+ // A closed banner is gone and a minimized one is a floating corner pill,
366
+ // so neither displaces the page.
367
+ if (!el || el.classList.contains('newshound-banner-minimized')) {
368
+ applyPadding(0);
369
+ return;
370
+ }
371
+
372
+ // Measure only once the content's max-height transition has settled.
373
+ setTimeout(function() { applyPadding(el.offsetHeight); }, 300);
374
+ };
375
+
376
+ if (document.readyState === 'loading') {
377
+ document.addEventListener('DOMContentLoaded', window.newshoundUpdatePadding);
378
+ } else {
379
+ window.newshoundUpdatePadding();
380
+ }
381
+
382
+ window.addEventListener('resize', window.newshoundUpdatePadding);
383
+ JS
384
+ end
385
+
171
386
  def render_styles
172
387
  <<~CSS
173
388
  <style id="newshound-styles">
174
- html body {
175
- padding-top: 50px;
176
- transition: padding-top 0.3s ease-out;
177
- }
389
+ #{render_body_padding_styles}
390
+ /* Everything that varies by position or state is a custom property set
391
+ on the banner. The rules below inherit those values, so each element
392
+ is styled by its own flat selector instead of by its ancestry. */
178
393
  .newshound-banner {
394
+ --newshound-content-display: block;
395
+ --newshound-content-max-height: 400px;
396
+ --newshound-content-overflow: auto;
397
+ --newshound-divider: 1px solid rgba(255,255,255,0.2);
398
+ --newshound-header-display: flex;
399
+ --newshound-restore-display: none;
400
+ --newshound-corner-radius: 0;
179
401
  position: fixed;
180
- top: 0;
181
402
  left: 0;
182
403
  right: 0;
183
404
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
184
405
  color: white;
185
406
  z-index: 10000;
186
- box-shadow: 0 2px 10px rgba(0,0,0,0.3);
407
+ box-shadow: var(--newshound-shadow);
408
+ border-radius: var(--newshound-corner-radius);
187
409
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
188
410
  font-size: 14px;
189
411
  }
412
+ .newshound-banner-top {
413
+ --newshound-shadow: 0 2px 10px rgba(0,0,0,0.3);
414
+ --newshound-toggle-rotation: 0deg;
415
+ --newshound-collapsed-toggle-rotation: -90deg;
416
+ --newshound-minimized-shadow: -2px 2px 6px rgba(0,0,0,0.2);
417
+ --newshound-minimized-corner-radius: 0 0 0 8px;
418
+ top: 0;
419
+ }
420
+ /* Laid out in reverse so the header still sits on the viewport edge and
421
+ the panel expands upward into the page. */
422
+ .newshound-banner-bottom {
423
+ --newshound-shadow: 0 -2px 10px rgba(0,0,0,0.3);
424
+ --newshound-toggle-rotation: 180deg;
425
+ --newshound-collapsed-toggle-rotation: 270deg;
426
+ --newshound-minimized-shadow: -2px -2px 6px rgba(0,0,0,0.2);
427
+ --newshound-minimized-corner-radius: 8px 0 0 0;
428
+ bottom: 0;
429
+ display: flex;
430
+ flex-direction: column-reverse;
431
+ }
432
+ /* State comes after position so it wins the properties they share. */
433
+ .newshound-banner-collapsed {
434
+ --newshound-content-max-height: 0px;
435
+ --newshound-content-overflow: hidden;
436
+ --newshound-divider: none;
437
+ --newshound-toggle-rotation: var(--newshound-collapsed-toggle-rotation);
438
+ }
439
+ .newshound-banner-minimized {
440
+ /* Taken out of layout rather than collapsed to zero height: a
441
+ shrink-to-fit banner otherwise sizes itself to the hidden
442
+ content's width. */
443
+ --newshound-content-display: none;
444
+ --newshound-header-display: none;
445
+ --newshound-restore-display: flex;
446
+ --newshound-shadow: var(--newshound-minimized-shadow);
447
+ --newshound-corner-radius: var(--newshound-minimized-corner-radius);
448
+ left: auto;
449
+ }
190
450
  .newshound-header {
191
451
  padding: 12px 20px;
192
452
  cursor: pointer;
193
- display: flex;
453
+ display: var(--newshound-header-display, flex);
194
454
  justify-content: space-between;
195
455
  align-items: center;
196
456
  user-select: none;
@@ -220,20 +480,21 @@ module Newshound
220
480
  }
221
481
  .newshound-toggle {
222
482
  transition: transform 0.3s;
223
- }
224
- .newshound-banner.newshound-collapsed .newshound-toggle {
225
- transform: rotate(-90deg);
483
+ transform: rotate(var(--newshound-toggle-rotation, 0deg));
226
484
  }
227
485
  .newshound-content {
228
- max-height: 400px;
229
- overflow-y: auto;
230
- border-top: 1px solid rgba(255,255,255,0.2);
486
+ display: var(--newshound-content-display, block);
487
+ max-height: var(--newshound-content-max-height, 400px);
488
+ overflow: var(--newshound-content-overflow, auto);
231
489
  transition: max-height 0.3s ease-out;
232
490
  }
233
- .newshound-banner.newshound-collapsed .newshound-content {
234
- max-height: 0;
235
- overflow: hidden;
236
- border-top: none;
491
+ /* The divider sits between the header and the panel, which means the
492
+ top edge of the content at the top and its bottom edge at the bottom. */
493
+ .newshound-content-top {
494
+ border-top: var(--newshound-divider);
495
+ }
496
+ .newshound-content-bottom {
497
+ border-bottom: var(--newshound-divider);
237
498
  }
238
499
  .newshound-section {
239
500
  padding: 15px 20px;
@@ -306,64 +567,84 @@ module Newshound
306
567
  align-items: center;
307
568
  gap: 12px;
308
569
  }
309
- .newshound-minimize {
310
- cursor: pointer;
311
- font-size: 18px;
312
- font-weight: 700;
570
+ .newshound-control {
571
+ background: none;
572
+ border: 0;
573
+ margin: 0;
574
+ padding: 0 4px;
575
+ color: inherit;
576
+ font: inherit;
313
577
  line-height: 1;
578
+ cursor: pointer;
314
579
  opacity: 0.7;
315
- padding: 0 4px;
316
580
  }
317
- .newshound-minimize:hover {
581
+ .newshound-control:hover,
582
+ .newshound-control:focus-visible {
318
583
  opacity: 1;
319
584
  }
320
- .newshound-restore {
321
- display: none;
322
- }
323
- .newshound-banner.newshound-minimized .newshound-header {
324
- display: none;
585
+ .newshound-control:focus-visible {
586
+ outline: 2px solid #fff;
587
+ outline-offset: 2px;
325
588
  }
326
- .newshound-banner.newshound-minimized .newshound-content {
327
- max-height: 0;
328
- overflow: hidden;
329
- border-top: none;
589
+ .newshound-minimize,
590
+ .newshound-close {
591
+ font-size: 18px;
592
+ font-weight: 700;
330
593
  }
331
- .newshound-banner.newshound-minimized .newshound-restore {
332
- display: flex;
594
+ .newshound-restore {
595
+ display: var(--newshound-restore-display, none);
333
596
  align-items: center;
334
597
  justify-content: center;
335
598
  width: 36px;
336
599
  height: 36px;
337
- cursor: pointer;
600
+ padding: 0;
601
+ opacity: 1;
338
602
  font-size: 16px;
339
603
  user-select: none;
340
604
  }
341
- .newshound-banner.newshound-minimized {
342
- left: auto;
343
- border-radius: 0 0 0 8px;
344
- box-shadow: -2px 2px 6px rgba(0,0,0,0.2);
345
- }
346
605
  </style>
347
606
  CSS
348
607
  end
349
608
 
609
+ def bottom?
610
+ Newshound.configuration.position == :bottom
611
+ end
612
+
613
+ # Position is settled before a byte of HTML goes out, so elements that differ
614
+ # between the two edges say so themselves -- "newshound-content
615
+ # newshound-content-bottom" -- rather than making the stylesheet work it out
616
+ # from an ancestor.
617
+ def positioned_class(name)
618
+ "newshound-#{name} newshound-#{name}-#{Newshound.configuration.position}"
619
+ end
620
+
621
+ # Only a top banner pushes the page down; a bottom one floats over it.
622
+ def render_body_padding_styles
623
+ return "" if bottom?
624
+
625
+ <<~CSS
626
+ html body {
627
+ padding-top: 50px;
628
+ transition: padding-top 0.3s ease-out;
629
+ }
630
+ CSS
631
+ end
632
+
350
633
  def summary_badge(exception_data, job_data, warning_data = {})
351
634
  exception_count = exception_data[:exceptions]&.length || 0
352
- failed_jobs = job_data.dig(:queue_stats, :failed) || 0
635
+ expired_jobs = job_data.dig(:queue_stats, :expired) || 0
353
636
  warning_count = warning_data[:warnings]&.length || 0
637
+ threshold = Newshound.configuration.expired_jobs_threshold
354
638
 
355
- if exception_count > 0 || failed_jobs > 10
639
+ if exception_count > 0
356
640
  badge_class = "newshound-error"
357
- text = "#{exception_count} exceptions, #{failed_jobs} failed jobs"
358
- elsif warning_count > 0 || failed_jobs > 5
641
+ text = "#{exception_count} exceptions"
642
+ else
359
643
  badge_class = "newshound-warning"
360
644
  parts = []
361
645
  parts << "#{warning_count} warnings" if warning_count > 0
362
- parts << "#{failed_jobs} failed jobs" if failed_jobs > 5
646
+ parts << "#{expired_jobs} expired jobs" if expired_jobs > threshold
363
647
  text = parts.join(", ")
364
- else
365
- badge_class = "newshound-success"
366
- text = "All clear"
367
648
  end
368
649
 
369
650
  %(<span class="newshound-badge #{badge_class}">#{text}</span>)
@@ -437,13 +718,17 @@ module Newshound
437
718
  section_title
438
719
  end
439
720
 
721
+ # :failed is the pre-1.0.4 link key and pointed at the retry queue.
722
+ failing_link = links[:failing] || links[:failed]
723
+
440
724
  <<~HTML
441
725
  <div class="newshound-section">
442
726
  <div class="newshound-section-title">#{title_html}</div>
443
727
  <div class="newshound-grid">
444
728
  #{render_job_stat(stats[:ready_to_run] || 0, "Ready", links[:index])}
445
729
  #{render_job_stat(stats[:scheduled] || 0, "Scheduled", links[:scheduled])}
446
- #{render_job_stat(stats[:failed] || 0, "Failed", links[:failed])}
730
+ #{render_job_stat(stats[:failing] || 0, "Failing", failing_link)}
731
+ #{render_job_stat(stats[:expired] || 0, "Expired", links[:expired])}
447
732
  #{render_job_stat(stats[:completed_today] || 0, "Completed Today", links[:completed])}
448
733
  </div>
449
734
  </div>
@@ -465,6 +750,7 @@ module Newshound
465
750
 
466
751
  def escape_html(text)
467
752
  return +"" unless text.present?
753
+
468
754
  text.to_s
469
755
  .gsub("&", "&amp;")
470
756
  .gsub("<", "&lt;")
@@ -42,7 +42,8 @@ module Newshound
42
42
  puts "Job Queue Report:"
43
43
  puts " Ready to run: #{stats[:ready_to_run] || 0}"
44
44
  puts " Scheduled: #{stats[:scheduled] || 0}"
45
- puts " Failed: #{stats[:failed] || 0}"
45
+ puts " Failing: #{stats[:failing] || 0}"
46
+ puts " Expired: #{stats[:expired] || 0}"
46
47
  puts " Completed today: #{stats[:completed_today] || 0}"
47
48
  end
48
49
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Newshound
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: newshound
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Savannah Moore