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 +4 -4
- data/CHANGELOG.md +21 -7
- data/README.md +105 -8
- data/lib/generators/newshound/install/templates/newshound.rb +28 -17
- data/lib/newshound/configuration.rb +22 -2
- data/lib/newshound/exceptions/solid_errors.rb +72 -11
- data/lib/newshound/job_reporter.rb +16 -5
- data/lib/newshound/jobs/base.rb +10 -3
- data/lib/newshound/jobs/que.rb +21 -15
- data/lib/newshound/middleware/banner_injector.rb +381 -104
- data/lib/newshound/railtie.rb +2 -1
- data/lib/newshound/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1f84ab484771f1f20e2ef1e27877c0eec621e1a260cc7439a67ec0d2c750036
|
|
4
|
+
data.tar.gz: 4eb10cad58543c5b40897beedea39ce7ccfeb438303c8de36ae0577a2f2a2a3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
8
|
+
## [1.1.0] - 2026-08-04
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
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** -
|
|
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
|
|
96
|
-
5. 🚀 No
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
:
|
|
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
|
-
@
|
|
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:* #{
|
|
17
|
-
#{
|
|
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:
|
|
39
|
+
id: banner_id(exception),
|
|
27
40
|
title: details[:title],
|
|
28
41
|
message: details[:message].truncate(100),
|
|
29
42
|
location: details[:location],
|
|
30
|
-
time:
|
|
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:
|
|
96
|
+
message: message.to_s,
|
|
46
97
|
location: (controller && action) ? "#{controller}##{action}" : +"",
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
68
|
-
return +""
|
|
128
|
+
def format_location(details)
|
|
129
|
+
return +"" if details[:location].blank?
|
|
69
130
|
|
|
70
|
-
"•
|
|
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
|
-
|
|
64
|
-
lines << "• #{
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
• *
|
|
103
|
+
• *Failing (Will Retry):* #{failing}
|
|
104
|
+
• *Expired (Out of Retries):* #{expired}
|
|
94
105
|
• *Completed Today:* #{stats[:finished_today]}
|
|
95
106
|
TEXT
|
|
96
107
|
end
|
data/lib/newshound/jobs/base.rb
CHANGED
|
@@ -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, :
|
|
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,
|
|
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
|
}
|
data/lib/newshound/jobs/que.rb
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
count = row["count"].to_i
|
|
47
|
+
failing = row["failing"].to_i
|
|
48
|
+
expired = row["expired"].to_i
|
|
41
49
|
|
|
42
|
-
hash[job_class]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
75
|
+
expired_jobs = job_data.dig(:queue_stats, :expired) || 0
|
|
74
76
|
warning_count = warning_data[:warnings]&.length || 0
|
|
75
|
-
threshold = Newshound.configuration.
|
|
77
|
+
threshold = Newshound.configuration.expired_jobs_threshold
|
|
76
78
|
|
|
77
|
-
exception_count > 0 || warning_count > 0 ||
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
<div class="newshound-header"
|
|
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
|
-
|
|
91
|
-
<span class="newshound-toggle">▼</span>
|
|
94
|
+
#{render_header_controls}
|
|
92
95
|
</span>
|
|
93
96
|
</div>
|
|
94
|
-
<div class="
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
document.getElementById('newshound-banner').classList.add('newshound-minimized');
|
|
157
|
+
function banner() {
|
|
158
|
+
return document.getElementById('newshound-banner');
|
|
114
159
|
}
|
|
115
160
|
|
|
116
|
-
|
|
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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
var
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
for (var i = 0; i <
|
|
151
|
-
if (
|
|
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
|
-
|
|
218
|
+
|
|
219
|
+
return false;
|
|
154
220
|
}
|
|
155
221
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
227
|
+
var known = seen ? seen.split(',') : [];
|
|
228
|
+
var ids = current.split(',');
|
|
163
229
|
|
|
164
|
-
|
|
230
|
+
for (var i = 0; i < ids.length; i++) {
|
|
231
|
+
if (known.indexOf(ids[i]) === -1) return true;
|
|
232
|
+
}
|
|
165
233
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
};
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
169
236
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
border-top:
|
|
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-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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-
|
|
581
|
+
.newshound-control:hover,
|
|
582
|
+
.newshound-control:focus-visible {
|
|
329
583
|
opacity: 1;
|
|
330
584
|
}
|
|
331
|
-
.newshound-
|
|
332
|
-
|
|
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-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
589
|
+
.newshound-minimize,
|
|
590
|
+
.newshound-close {
|
|
591
|
+
font-size: 18px;
|
|
592
|
+
font-weight: 700;
|
|
341
593
|
}
|
|
342
|
-
.newshound-
|
|
343
|
-
display:
|
|
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
|
-
|
|
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
|
-
|
|
635
|
+
expired_jobs = job_data.dig(:queue_stats, :expired) || 0
|
|
364
636
|
warning_count = warning_data[:warnings]&.length || 0
|
|
365
|
-
threshold = Newshound.configuration.
|
|
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 << "#{
|
|
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[:
|
|
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("&", "&")
|
|
479
756
|
.gsub("<", "<")
|
data/lib/newshound/railtie.rb
CHANGED
|
@@ -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 "
|
|
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
|
data/lib/newshound/version.rb
CHANGED