newshound 1.0.3 → 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: f63cb577cdf9cc38edce00142a140f07263b9a59ddad4308fa4956b796617e60
4
- data.tar.gz: ba96c4b481cc3de1b462bba4b53a482e0ece9ac05ebaebc56f65e601b638a2d2
3
+ metadata.gz: e1f84ab484771f1f20e2ef1e27877c0eec621e1a260cc7439a67ec0d2c750036
4
+ data.tar.gz: 4eb10cad58543c5b40897beedea39ce7ccfeb438303c8de36ae0577a2f2a2a3e
5
5
  SHA512:
6
- metadata.gz: fb61b7b3f7bb65a5f0f888738ad78087cdc248e0dc512e82f467d4f85314428359443e6e3e5219e3d19c59e7cb63d9594b00e9d35505b4cda7e5e90ec25c9ad5
7
- data.tar.gz: d86c02b4f21c5f2fc1780846848163a5d1205256c797f5c4138f1eed1ab1fb3c6003b10bdd60735232b375bb5cbf08196c1585bdd6f0d27edfa969896b07e6d4
6
+ metadata.gz: 0d867822e4cd529f0e9b2a2a624ff586da589ccac751cd14f0355ca5bd1bf1643f95ee4ac3ed357ae98a1ccaeb2b7ed437020d2a475507932ae2bf10c69ff596
7
+ data.tar.gz: e1c6f082a2067af2fc8261a0416bb45b53b1f03147976eb221734bd8cd734eeec220aaa76bad8e35e382264c113f22784a10b2f58476bd9b6630f6e5ec0a4c2a
data/CHANGELOG.md CHANGED
@@ -5,20 +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.3] - 2026-04-21
8
+ ## [1.1.0] - 2026-08-04
9
9
 
10
10
  ### Added
11
11
 
12
- - failed_jobs_threshold configuration option (0caac13)
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)
13
15
 
14
16
  ### Changed
15
17
 
16
- - Skip banner injection when there is nothing notable to display (0caac13)
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)
17
22
 
18
- ## [1.0.2] - 2026-04-03
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
19
31
 
20
32
  ### Added
21
33
 
22
- - Bugsink exception source adapter for HTTP-based error tracking (6d3e2a1)
23
- - exception_source_config option for passing connection details to adapters (6d3e2a1)
24
- - Minimize button for banner (c686227)
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,31 +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
- # Number of failed jobs at or below which the banner is suppressed
23
- # Set higher if your app tolerates occasional background job failures
24
- # Default is 0 (any failed job triggers the banner)
25
- # config.failed_jobs_threshold = 0
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
26
36
 
27
- # User roles that are authorized to view the Newshound banner
28
- # These should match the role values in your User model
37
+ # User roles that are authorized to view the Newshound banner These should match
38
+ # the role values in your User model
29
39
  # Default is [:developer, :super_user]
30
40
  config.authorized_roles = [:developer, :super_user]
31
41
 
@@ -34,8 +44,8 @@ Newshound.configure do |config|
34
44
  # Default is :current_user
35
45
  config.current_user_method = :current_user
36
46
 
37
- # Links for banner items
38
- # 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.
39
49
  # Use :id in show paths to interpolate the record ID.
40
50
  #
41
51
  # config.exception_links = {
@@ -47,7 +57,8 @@ Newshound.configure do |config|
47
57
  # index: "/background_jobs",
48
58
  # show: "/background_jobs/jobs/:id",
49
59
  # scheduled: "/background_jobs/scheduled",
50
- # failed: "/background_jobs/failed",
60
+ # failing: "/background_jobs/failing",
61
+ # expired: "/background_jobs/expired",
51
62
  # completed: "/background_jobs/completed"
52
63
  # }
53
64
  #
@@ -2,13 +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,
9
- :failed_jobs_threshold,
11
+ :expired_jobs_threshold,
10
12
  :exception_links, :job_links, :warning_links
11
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
+
12
21
  def initialize
13
22
  @exception_limit = 10
14
23
  @enabled = true
@@ -20,10 +29,21 @@ module Newshound
20
29
  @warning_source = nil
21
30
  @warning_limit = 10
22
31
  @job_source = nil
23
- @failed_jobs_threshold = 0
32
+ @expired_jobs_threshold = 0
24
33
  @exception_links = {}
25
34
  @job_links = {}
26
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
27
47
  end
28
48
 
29
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}"
@@ -68,140 +68,389 @@ module Newshound
68
68
  render_banner(exception_data, job_data, warning_data)
69
69
  end
70
70
 
71
+ # Only expired jobs are worth interrupting someone over. Failing jobs still
72
+ # have retries left and usually clear themselves.
71
73
  def notable_data?(exception_data, job_data, warning_data)
72
74
  exception_count = exception_data[:exceptions]&.length || 0
73
- failed_jobs = job_data.dig(:queue_stats, :failed) || 0
75
+ expired_jobs = job_data.dig(:queue_stats, :expired) || 0
74
76
  warning_count = warning_data[:warnings]&.length || 0
75
- threshold = Newshound.configuration.failed_jobs_threshold
77
+ threshold = Newshound.configuration.expired_jobs_threshold
76
78
 
77
- exception_count > 0 || warning_count > 0 || failed_jobs > threshold
79
+ exception_count > 0 || warning_count > 0 || expired_jobs > threshold
78
80
  end
79
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.
80
84
  def render_banner(exception_data, job_data, warning_data = {})
81
85
  <<~HTML
82
- <div id="newshound-banner" class="newshound-banner newshound-collapsed">
83
- #{render_styles}
84
- <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">
85
89
  <span class="newshound-title">
86
90
  🐕 Newshound
87
91
  #{summary_badge(exception_data, job_data, warning_data)}
88
92
  </span>
89
93
  <span class="newshound-header-controls">
90
- <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>
91
- <span class="newshound-toggle">▼</span>
94
+ #{render_header_controls}
92
95
  </span>
93
96
  </div>
94
- <div class="newshound-content">
97
+ <div class="#{positioned_class(:content)}">
95
98
  #{render_exceptions(exception_data)}
96
99
  #{render_warnings(warning_data)}
97
100
  #{render_jobs(job_data)}
98
101
  </div>
99
- <div class="newshound-restore" onclick="document.getElementById('newshound-banner').classList.remove('newshound-minimized'); localStorage.removeItem('newshound-minimized'); window.newshoundUpdatePadding();" title="Restore Newshound">
100
- 🐕
101
- </div>
102
+ #{render_restore_control}
102
103
  </div>
103
104
  #{render_script}
104
105
  HTML
105
106
  end
106
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
+
107
153
  def render_script
108
154
  <<~JS
109
155
  <script>
110
156
  (function() {
111
- // Restore minimized state from localStorage
112
- if (localStorage.getItem('newshound-minimized')) {
113
- document.getElementById('newshound-banner').classList.add('newshound-minimized');
157
+ function banner() {
158
+ return document.getElementById('newshound-banner');
114
159
  }
115
160
 
116
- 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');
117
164
 
118
- // Detect once whether any non-newshound stylesheet uses !important on body padding-top
119
- function detectPriority() {
120
- var styles = document.querySelectorAll('style:not(#newshound-styles)');
121
-
122
- for (var i = 0; i < styles.length; i++) {
123
- if (styles[i].textContent.match(/body\\s*{[^}]*padding-top[^;]*!important/s)) {
124
- return 'important';
125
- }
126
- }
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
+ }
127
173
 
174
+ function forgetMinimized() {
128
175
  try {
129
- for (var s = 0; s < document.styleSheets.length; s++) {
130
- var sheet = document.styleSheets[s];
131
- if (sheet.ownerNode && sheet.ownerNode.id === 'newshound-styles') continue;
132
- var rules = sheet.cssRules || [];
133
- for (var r = 0; r < rules.length; r++) {
134
- if (rules[r].selectorText === 'body' &&
135
- rules[r].style.getPropertyPriority('padding-top') === 'important') {
136
- return 'important';
137
- }
138
- }
139
- }
176
+ localStorage.removeItem('newshound-minimized');
177
+ localStorage.removeItem('newshound-signature');
140
178
  } catch (e) {}
179
+ }
180
+
181
+ function wasMinimized() {
182
+ try {
183
+ return localStorage.getItem('newshound-minimized') === '1';
184
+ } catch (e) {
185
+ return false;
186
+ }
187
+ }
188
+
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;
205
+ }
141
206
 
142
- return '';
207
+ return false;
143
208
  }
144
209
 
145
- // Find the html body rule in the newshound stylesheet once
146
- function findBodyRule() {
147
- var styleEl = document.getElementById('newshound-styles');
148
- if (!styleEl || !styleEl.sheet) return null;
149
- var rules = styleEl.sheet.cssRules;
150
- for (var i = 0; i < rules.length; i++) {
151
- if (rules[i].selectorText === 'html body') return rules[i];
210
+ function hasRisen(counts, seenCounts) {
211
+ var now = counts.split(',');
212
+ var before = seenCounts.split(',');
213
+ if (now.length !== before.length) return true;
214
+
215
+ for (var i = 0; i < now.length; i++) {
216
+ if (Number(now[i]) > Number(before[i])) return true;
152
217
  }
153
- return null;
218
+
219
+ return false;
154
220
  }
155
221
 
156
- window.newshoundUpdatePadding = function() {
157
- setTimeout(function() {
158
- var banner = document.getElementById('newshound-banner');
159
- if (!banner) return;
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;
160
226
 
161
- if (cachedBodyRule === undefined) cachedBodyRule = findBodyRule();
162
- if (!cachedBodyRule) return;
227
+ var known = seen ? seen.split(',') : [];
228
+ var ids = current.split(',');
163
229
 
164
- if (cachedPriority === undefined) cachedPriority = detectPriority();
230
+ for (var i = 0; i < ids.length; i++) {
231
+ if (known.indexOf(ids[i]) === -1) return true;
232
+ }
165
233
 
166
- cachedBodyRule.style.setProperty('padding-top', banner.offsetHeight + 'px', cachedPriority);
167
- }, 300);
168
- };
234
+ return false;
235
+ }
169
236
 
170
- if (document.readyState === 'loading') {
171
- document.addEventListener('DOMContentLoaded', window.newshoundUpdatePadding);
172
- } else {
173
- 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
+ }
174
246
  }
175
247
 
176
- window.addEventListener('resize', window.newshoundUpdatePadding);
248
+ #{render_controls_script}
249
+
250
+ #{render_padding_script}
177
251
  })();
178
252
  </script>
179
253
  JS
180
254
  end
181
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
+
182
386
  def render_styles
183
387
  <<~CSS
184
388
  <style id="newshound-styles">
185
- html body {
186
- padding-top: 50px;
187
- transition: padding-top 0.3s ease-out;
188
- }
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. */
189
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;
190
401
  position: fixed;
191
- top: 0;
192
402
  left: 0;
193
403
  right: 0;
194
404
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
195
405
  color: white;
196
406
  z-index: 10000;
197
- box-shadow: 0 2px 10px rgba(0,0,0,0.3);
407
+ box-shadow: var(--newshound-shadow);
408
+ border-radius: var(--newshound-corner-radius);
198
409
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
199
410
  font-size: 14px;
200
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
+ }
201
450
  .newshound-header {
202
451
  padding: 12px 20px;
203
452
  cursor: pointer;
204
- display: flex;
453
+ display: var(--newshound-header-display, flex);
205
454
  justify-content: space-between;
206
455
  align-items: center;
207
456
  user-select: none;
@@ -231,20 +480,21 @@ module Newshound
231
480
  }
232
481
  .newshound-toggle {
233
482
  transition: transform 0.3s;
234
- }
235
- .newshound-banner.newshound-collapsed .newshound-toggle {
236
- transform: rotate(-90deg);
483
+ transform: rotate(var(--newshound-toggle-rotation, 0deg));
237
484
  }
238
485
  .newshound-content {
239
- max-height: 400px;
240
- overflow-y: auto;
241
- 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);
242
489
  transition: max-height 0.3s ease-out;
243
490
  }
244
- .newshound-banner.newshound-collapsed .newshound-content {
245
- max-height: 0;
246
- overflow: hidden;
247
- 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);
248
498
  }
249
499
  .newshound-section {
250
500
  padding: 15px 20px;
@@ -317,52 +567,74 @@ module Newshound
317
567
  align-items: center;
318
568
  gap: 12px;
319
569
  }
320
- .newshound-minimize {
321
- cursor: pointer;
322
- font-size: 18px;
323
- 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;
324
577
  line-height: 1;
578
+ cursor: pointer;
325
579
  opacity: 0.7;
326
- padding: 0 4px;
327
580
  }
328
- .newshound-minimize:hover {
581
+ .newshound-control:hover,
582
+ .newshound-control:focus-visible {
329
583
  opacity: 1;
330
584
  }
331
- .newshound-restore {
332
- display: none;
333
- }
334
- .newshound-banner.newshound-minimized .newshound-header {
335
- display: none;
585
+ .newshound-control:focus-visible {
586
+ outline: 2px solid #fff;
587
+ outline-offset: 2px;
336
588
  }
337
- .newshound-banner.newshound-minimized .newshound-content {
338
- max-height: 0;
339
- overflow: hidden;
340
- border-top: none;
589
+ .newshound-minimize,
590
+ .newshound-close {
591
+ font-size: 18px;
592
+ font-weight: 700;
341
593
  }
342
- .newshound-banner.newshound-minimized .newshound-restore {
343
- display: flex;
594
+ .newshound-restore {
595
+ display: var(--newshound-restore-display, none);
344
596
  align-items: center;
345
597
  justify-content: center;
346
598
  width: 36px;
347
599
  height: 36px;
348
- cursor: pointer;
600
+ padding: 0;
601
+ opacity: 1;
349
602
  font-size: 16px;
350
603
  user-select: none;
351
604
  }
352
- .newshound-banner.newshound-minimized {
353
- left: auto;
354
- border-radius: 0 0 0 8px;
355
- box-shadow: -2px 2px 6px rgba(0,0,0,0.2);
356
- }
357
605
  </style>
358
606
  CSS
359
607
  end
360
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
+
361
633
  def summary_badge(exception_data, job_data, warning_data = {})
362
634
  exception_count = exception_data[:exceptions]&.length || 0
363
- failed_jobs = job_data.dig(:queue_stats, :failed) || 0
635
+ expired_jobs = job_data.dig(:queue_stats, :expired) || 0
364
636
  warning_count = warning_data[:warnings]&.length || 0
365
- threshold = Newshound.configuration.failed_jobs_threshold
637
+ threshold = Newshound.configuration.expired_jobs_threshold
366
638
 
367
639
  if exception_count > 0
368
640
  badge_class = "newshound-error"
@@ -371,7 +643,7 @@ module Newshound
371
643
  badge_class = "newshound-warning"
372
644
  parts = []
373
645
  parts << "#{warning_count} warnings" if warning_count > 0
374
- parts << "#{failed_jobs} failed jobs" if failed_jobs > threshold
646
+ parts << "#{expired_jobs} expired jobs" if expired_jobs > threshold
375
647
  text = parts.join(", ")
376
648
  end
377
649
 
@@ -446,13 +718,17 @@ module Newshound
446
718
  section_title
447
719
  end
448
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
+
449
724
  <<~HTML
450
725
  <div class="newshound-section">
451
726
  <div class="newshound-section-title">#{title_html}</div>
452
727
  <div class="newshound-grid">
453
728
  #{render_job_stat(stats[:ready_to_run] || 0, "Ready", links[:index])}
454
729
  #{render_job_stat(stats[:scheduled] || 0, "Scheduled", links[:scheduled])}
455
- #{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])}
456
732
  #{render_job_stat(stats[:completed_today] || 0, "Completed Today", links[:completed])}
457
733
  </div>
458
734
  </div>
@@ -474,6 +750,7 @@ module Newshound
474
750
 
475
751
  def escape_html(text)
476
752
  return +"" unless text.present?
753
+
477
754
  text.to_s
478
755
  .gsub("&", "&amp;")
479
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.3"
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.3
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Savannah Moore