rails_webhook_outbox 0.3.0 → 0.4.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/README.md +65 -1
- data/Rakefile +6 -1
- data/UPGRADING.md +92 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_01_base.css +53 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_02_layout.css +88 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_03_table.css +26 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_04_badges.css +19 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_05_forms.css +42 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_06_filters.css +13 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_07_timeline.css +31 -0
- data/app/controllers/rails_webhook_outbox/base_controller.rb +15 -0
- data/app/controllers/rails_webhook_outbox/deliveries_controller.rb +46 -0
- data/app/controllers/rails_webhook_outbox/events_controller.rb +13 -0
- data/app/controllers/rails_webhook_outbox/overview_controller.rb +7 -0
- data/app/controllers/rails_webhook_outbox/subscriptions_controller.rb +62 -0
- data/app/helpers/rails_webhook_outbox/application_helper.rb +29 -0
- data/app/javascript/rails_webhook_outbox/application.js +2 -0
- data/app/javascript/rails_webhook_outbox/controllers/application.js +4 -0
- data/app/javascript/rails_webhook_outbox/controllers/index.js +4 -0
- data/app/javascript/rails_webhook_outbox/controllers/secret_controller.js +24 -0
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +15 -0
- data/app/models/rails_webhook_outbox/delivery.rb +28 -0
- data/app/models/rails_webhook_outbox/delivery_attempt.rb +13 -0
- data/app/models/rails_webhook_outbox/overview.rb +49 -0
- data/app/models/rails_webhook_outbox/subscription.rb +6 -1
- data/app/views/layouts/rails_webhook_outbox/application.html.erb +22 -0
- data/app/views/rails_webhook_outbox/deliveries/_detail.html.erb +47 -0
- data/app/views/rails_webhook_outbox/deliveries/_row.html.erb +7 -0
- data/app/views/rails_webhook_outbox/deliveries/_timeline.html.erb +41 -0
- data/app/views/rails_webhook_outbox/deliveries/index.html.erb +62 -0
- data/app/views/rails_webhook_outbox/deliveries/retry.turbo_stream.erb +2 -0
- data/app/views/rails_webhook_outbox/deliveries/show.html.erb +5 -0
- data/app/views/rails_webhook_outbox/events/index.html.erb +22 -0
- data/app/views/rails_webhook_outbox/overview/_recent_failures.html.erb +29 -0
- data/app/views/rails_webhook_outbox/overview/_stats.html.erb +19 -0
- data/app/views/rails_webhook_outbox/overview/show.html.erb +8 -0
- data/app/views/rails_webhook_outbox/subscriptions/_form.html.erb +49 -0
- data/app/views/rails_webhook_outbox/subscriptions/edit.html.erb +7 -0
- data/app/views/rails_webhook_outbox/subscriptions/index.html.erb +37 -0
- data/app/views/rails_webhook_outbox/subscriptions/new.html.erb +7 -0
- data/app/views/rails_webhook_outbox/subscriptions/show.html.erb +49 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +1 -1
- data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +4 -4
- data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +2 -2
- data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +2 -2
- data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +1 -1
- data/db/migrate/20261027000001_create_webhook_outbox_delivery_attempts.rb +16 -0
- data/lib/generators/rails_webhook_outbox/install/install_generator.rb +1 -19
- data/lib/rails_webhook_outbox/configuration.rb +3 -1
- data/lib/rails_webhook_outbox/engine.rb +16 -0
- data/lib/rails_webhook_outbox/version.rb +1 -1
- metadata +67 -3
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +0 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4c853b16c9d48f970ebe2ccb83c7de3e1d6789d93a9e78407f788b6a5f8dd8d
|
|
4
|
+
data.tar.gz: a9a380da628a0f00c0d6d5ac08d2351edbc1e0fc3084b4d73ce6dab7e1f28c44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 452c56fedabd11ecd4a9388af0fa7de18b4988c60585c4b567a2b28d9657ee5226ba3b492bcab0752cc29be2d843e2c329092c32391e52311e85dfd460c24f85
|
|
7
|
+
data.tar.gz: e464b60386edada571d92ad803f0c074e2b152fb4438b63d4e843b1833ff0d65686074a8fe0cc62d3817b50241d28aebc068d47605443be5cc4c6e5a5defa657
|
data/README.md
CHANGED
|
@@ -23,12 +23,14 @@ A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
|
|
|
23
23
|
- [HMAC Signing](#hmac-signing)
|
|
24
24
|
- [Secret Rotation](#secret-rotation)
|
|
25
25
|
- [Circuit Breaker](#circuit-breaker)
|
|
26
|
+
- [Dashboard](#dashboard)
|
|
26
27
|
- [Rake Tasks](#rake-tasks)
|
|
27
28
|
- [Usage](#usage)
|
|
28
29
|
- [Manual Dispatch](#manual-dispatch)
|
|
29
30
|
- [Testing](#testing)
|
|
30
31
|
- [Development](#development)
|
|
31
32
|
- [Dummy App](#dummy-app)
|
|
33
|
+
- [Upgrading](#upgrading)
|
|
32
34
|
- [Contributing](#contributing)
|
|
33
35
|
- [License](#license)
|
|
34
36
|
|
|
@@ -46,13 +48,16 @@ And then execute:
|
|
|
46
48
|
$ bundle install
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
Run the install generator:
|
|
51
|
+
Run the install generator, then migrate:
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
54
|
$ rails generate rails_webhook_outbox:install
|
|
53
55
|
$ rails db:migrate
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
The generator creates `config/initializers/rails_webhook_outbox.rb`. It does not copy any migrations:
|
|
59
|
+
the engine adds its own to your app's migration paths, so `rails db:migrate` creates the tables.
|
|
60
|
+
|
|
56
61
|
## Configuration
|
|
57
62
|
|
|
58
63
|
```ruby
|
|
@@ -73,6 +78,7 @@ RailsWebhookOutbox.configure do |config|
|
|
|
73
78
|
config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
|
|
74
79
|
config.secret_rotation_grace_period = 24.hours
|
|
75
80
|
config.circuit_breaker_threshold = 10 # consecutive permanent failures before auto-disabling; nil or 0 disables
|
|
81
|
+
config.dashboard_enabled = true # enable the mountable dashboard UI; defaults to true in development
|
|
76
82
|
end
|
|
77
83
|
```
|
|
78
84
|
|
|
@@ -136,6 +142,23 @@ RailsWebhookOutbox::Delivery.delivered # successfully delivered
|
|
|
136
142
|
RailsWebhookOutbox::Delivery.failed # exhausted all retries
|
|
137
143
|
```
|
|
138
144
|
|
|
145
|
+
Every HTTP attempt is also recorded as a `RailsWebhookOutbox::DeliveryAttempt`, so earlier failures
|
|
146
|
+
are kept after a retry succeeds (the delivery itself only stores the latest response):
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
delivery.delivery_attempts.each do |attempt|
|
|
150
|
+
attempt.attempt_number # => 1, 2, 3, ...
|
|
151
|
+
attempt.outcome # => "failed" or "delivered"
|
|
152
|
+
attempt.response_code # => 503
|
|
153
|
+
attempt.response_body # => truncated to 1000 characters
|
|
154
|
+
attempt.duration_ms # => 182
|
|
155
|
+
attempt.created_at
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Attempts are deleted with their delivery, including by `webhook_outbox:cleanup`. Upgrading from a
|
|
160
|
+
version before 0.4.0 requires a migration — see [Upgrading](#upgrading).
|
|
161
|
+
|
|
139
162
|
[Back to top](#table-of-contents)
|
|
140
163
|
|
|
141
164
|
## Async Delivery
|
|
@@ -351,6 +374,37 @@ single subsequent failure won't instantly re-trip the breaker.
|
|
|
351
374
|
|
|
352
375
|
[Back to top](#table-of-contents)
|
|
353
376
|
|
|
377
|
+
## Dashboard
|
|
378
|
+
|
|
379
|
+
Mount the engine to get a browser dashboard for managing subscriptions and browsing deliveries:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
# config/routes.rb
|
|
383
|
+
mount RailsWebhookOutbox::Engine => "/admin/webhooks"
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The dashboard is gated by `config.dashboard_enabled` (default: `true` in development, `false`
|
|
387
|
+
elsewhere) — set it explicitly to control access, and put the mount behind your own
|
|
388
|
+
authentication/authorization in `routes.rb` (e.g. `constraints AdminConstraint.new do ... end`) since
|
|
389
|
+
the engine itself has no concept of users.
|
|
390
|
+
|
|
391
|
+
Pages:
|
|
392
|
+
|
|
393
|
+
| Page | Purpose |
|
|
394
|
+
|------|---------|
|
|
395
|
+
| `/` | Overview — subscription/delivery counts, recent failures |
|
|
396
|
+
| `/subscriptions` | List, create, and edit subscriptions; enable/disable with one click |
|
|
397
|
+
| `/subscriptions/:id` | Detail — masked signing secret with reveal/copy, rotate-secret button |
|
|
398
|
+
| `/deliveries` | Filterable delivery log (status, event, date range) with live status updates via Turbo Streams |
|
|
399
|
+
| `/deliveries/:id` | Detail — attempt timeline, payload, response, and a manual retry button for failed deliveries |
|
|
400
|
+
| `/events` | Registered events with subscriber counts and a recent payload example |
|
|
401
|
+
|
|
402
|
+
The UI ships its own assets (CSS inlined, JS via importmap) and follows the system light/dark color
|
|
403
|
+
scheme automatically — no asset pipeline configuration is required beyond what a standard Rails 7+
|
|
404
|
+
app already has.
|
|
405
|
+
|
|
406
|
+
[Back to top](#table-of-contents)
|
|
407
|
+
|
|
354
408
|
## Rake Tasks
|
|
355
409
|
|
|
356
410
|
```bash
|
|
@@ -534,6 +588,16 @@ RailsWebhookOutbox::Subscription.first.update!(url: "https://webhook.site/your-i
|
|
|
534
588
|
|
|
535
589
|
[Back to top](#table-of-contents)
|
|
536
590
|
|
|
591
|
+
## Upgrading
|
|
592
|
+
|
|
593
|
+
Some releases need action from you, such as a database migration. Read [UPGRADING.md](UPGRADING.md)
|
|
594
|
+
before updating the gem, and check the **BREAKING** entries in the [CHANGELOG](CHANGELOG.md).
|
|
595
|
+
|
|
596
|
+
**0.4.0 requires a new table** (`webhook_outbox_delivery_attempts`): run `bin/rails db:migrate`
|
|
597
|
+
before deploying. See [Upgrading to 0.4.0](UPGRADING.md#upgrading-to-040).
|
|
598
|
+
|
|
599
|
+
[Back to top](#table-of-contents)
|
|
600
|
+
|
|
537
601
|
## Contributing
|
|
538
602
|
|
|
539
603
|
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_webhook_outbox).
|
data/Rakefile
CHANGED
|
@@ -13,4 +13,9 @@ RuboCop::RakeTask.new(:lint)
|
|
|
13
13
|
Bundler::Audit::Task.new
|
|
14
14
|
RSpec::Core::RakeTask.new(:spec)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
desc "Run Brakeman static security analysis"
|
|
17
|
+
task :brakeman do
|
|
18
|
+
sh "bundle exec brakeman -q --no-pager --force-scan --skip-files spec/ ."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :brakeman, :spec]
|
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Upgrading
|
|
2
|
+
|
|
3
|
+
Notes for upgrades that need action from you, newest version first. For everything that changed in a
|
|
4
|
+
release, see the [CHANGELOG](CHANGELOG.md).
|
|
5
|
+
|
|
6
|
+
## Upgrading to 0.4.0
|
|
7
|
+
|
|
8
|
+
**This release has a breaking change: it requires a new database table.** Run the migration _before_
|
|
9
|
+
the 0.4.0 code starts processing deliveries.
|
|
10
|
+
|
|
11
|
+
### What changed
|
|
12
|
+
|
|
13
|
+
`DeliveryJob` now records every HTTP attempt as a `RailsWebhookOutbox::DeliveryAttempt` in the new
|
|
14
|
+
`webhook_outbox_delivery_attempts` table. This is the data behind the delivery timeline in the
|
|
15
|
+
dashboard. Previously only the latest response was kept on the delivery itself.
|
|
16
|
+
|
|
17
|
+
### Steps
|
|
18
|
+
|
|
19
|
+
1. Update the gem:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle update rails_webhook_outbox
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
2. Run the migration. The engine adds its migrations to your app's migration paths, so there is
|
|
26
|
+
nothing to copy:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bin/rails db:migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. Confirm it ran:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bin/rails db:migrate:status | grep delivery_attempts
|
|
36
|
+
# up 20261027000001 Create webhook outbox delivery attempts
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
In a deploy pipeline, run `db:migrate` before the new code starts serving requests or running jobs
|
|
40
|
+
(the usual release-phase step on most hosts).
|
|
41
|
+
|
|
42
|
+
### If you ran the install generator on an earlier version
|
|
43
|
+
|
|
44
|
+
Before 0.4.0, `rails generate rails_webhook_outbox:install` also copied the engine's table migrations
|
|
45
|
+
into your app's `db/migrate`. The engine adds those same migrations to your app on its own, so with
|
|
46
|
+
the copies present `db:migrate` fails:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
ActiveRecord::DuplicateMigrationNameError: Multiple migrations have the name CreateWebhookOutboxSubscriptions.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To fix it, delete the copies and migrate. The engine's migrations now skip tables, columns, and
|
|
53
|
+
indexes that already exist, so they are safe to run against a database the copies already built:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
rm db/migrate/*_create_webhook_outbox_subscriptions.rb \
|
|
57
|
+
db/migrate/*_create_webhook_outbox_deliveries.rb
|
|
58
|
+
bin/rails db:migrate
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Commit the deletions. `db:migrate:status` will keep listing the old copied versions as `NO FILE`;
|
|
62
|
+
that is harmless. The run also adds anything the copies lacked, such as the unique index on
|
|
63
|
+
`idempotency_key`, and creates the new `webhook_outbox_delivery_attempts` table.
|
|
64
|
+
|
|
65
|
+
### If you deploy the code without migrating
|
|
66
|
+
|
|
67
|
+
`DeliveryJob` sends the HTTP request first and records the attempt afterwards. Without the table,
|
|
68
|
+
recording the attempt raises `ActiveRecord::StatementInvalid`, so:
|
|
69
|
+
|
|
70
|
+
- the webhook **is still sent** to the subscriber, but the delivery is left `pending` with
|
|
71
|
+
`attempts: 0`, and the engine's `DeliveryError` retry does not apply to this error;
|
|
72
|
+
- every job fails the same way until the migration is run.
|
|
73
|
+
|
|
74
|
+
Run `db:migrate`, then re-enqueue the stuck deliveries:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
RailsWebhookOutbox::Delivery.pending.find_each { |d| RailsWebhookOutbox::DeliveryJob.perform_later(d) }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The subscriber will receive those webhooks a second time. It can deduplicate them with the
|
|
81
|
+
`X-Webhook-Delivery` header, which is stable across attempts. (`webhook_outbox:retry_failed` does not
|
|
82
|
+
help here: it only re-enqueues `failed` deliveries.)
|
|
83
|
+
|
|
84
|
+
### Existing data
|
|
85
|
+
|
|
86
|
+
Deliveries created before the upgrade have no attempt history; it starts with the first attempt made
|
|
87
|
+
after the migration. Nothing is backfilled.
|
|
88
|
+
|
|
89
|
+
### Downgrading
|
|
90
|
+
|
|
91
|
+
Downgrading the gem back to 0.3.0 is safe without rolling the migration back: 0.3.0 never reads the
|
|
92
|
+
table, and the foreign key cascades on delete, so `webhook_outbox:cleanup` keeps working.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #f8f9fa;
|
|
5
|
+
--surface: #ffffff;
|
|
6
|
+
--border: #dee2e6;
|
|
7
|
+
--text: #212529;
|
|
8
|
+
--muted: #6c757d;
|
|
9
|
+
--accent: #4f46e5;
|
|
10
|
+
--accent-text: #ffffff;
|
|
11
|
+
--danger: #dc3545;
|
|
12
|
+
--success-bg: #d1e7dd;
|
|
13
|
+
--success-text: #0f5132;
|
|
14
|
+
--warning-bg: #fff3cd;
|
|
15
|
+
--warning-text: #664d03;
|
|
16
|
+
--danger-bg: #f8d7da;
|
|
17
|
+
--danger-text: #842029;
|
|
18
|
+
--radius: 6px;
|
|
19
|
+
--shadow: 0 1px 3px rgba(0,0,0,.08);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #16181d;
|
|
25
|
+
--surface: #1f2229;
|
|
26
|
+
--border: #333844;
|
|
27
|
+
--text: #e9ecef;
|
|
28
|
+
--muted: #9aa1ac;
|
|
29
|
+
--accent: #7c7ff2;
|
|
30
|
+
--accent-text: #ffffff;
|
|
31
|
+
--danger: #e25563;
|
|
32
|
+
--success-bg: #10321f;
|
|
33
|
+
--success-text: #7fe0a4;
|
|
34
|
+
--warning-bg: #3a2f0b;
|
|
35
|
+
--warning-text: #f0c96b;
|
|
36
|
+
--danger-bg: #3a1216;
|
|
37
|
+
--danger-text: #f0949c;
|
|
38
|
+
--shadow: 0 1px 3px rgba(0,0,0,.4);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body {
|
|
43
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
44
|
+
font-size: 14px;
|
|
45
|
+
line-height: 1.5;
|
|
46
|
+
color: var(--text);
|
|
47
|
+
background: var(--bg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
a { color: var(--accent); }
|
|
51
|
+
|
|
52
|
+
.rwo-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 12px; }
|
|
53
|
+
.rwo-muted { color: var(--muted); font-size: 12px; }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
.rwo-nav {
|
|
2
|
+
display: flex;
|
|
3
|
+
gap: 1.5rem;
|
|
4
|
+
padding: 1rem 2rem;
|
|
5
|
+
background: var(--surface);
|
|
6
|
+
border-bottom: 1px solid var(--border);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rwo-nav-link {
|
|
10
|
+
color: var(--text);
|
|
11
|
+
text-decoration: none;
|
|
12
|
+
font-weight: 600;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.rwo-nav-link:hover { color: var(--accent); }
|
|
16
|
+
|
|
17
|
+
.rwo-main { padding: 2rem; max-width: 1100px; margin: 0 auto; }
|
|
18
|
+
|
|
19
|
+
.rwo-header { margin-bottom: 1.5rem; }
|
|
20
|
+
|
|
21
|
+
.rwo-header-row {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: space-between;
|
|
25
|
+
gap: 1rem;
|
|
26
|
+
margin-bottom: 1rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.rwo-section-title { margin: 1.5rem 0 0.75rem; font-size: 16px; }
|
|
30
|
+
|
|
31
|
+
.rwo-back { display: inline-block; margin-bottom: 1rem; font-size: 13px; }
|
|
32
|
+
|
|
33
|
+
.rwo-empty { color: var(--muted); padding: 1rem 0; }
|
|
34
|
+
|
|
35
|
+
.rwo-stats-grid {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
38
|
+
gap: 1rem;
|
|
39
|
+
margin-bottom: 2rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.rwo-stat {
|
|
43
|
+
background: var(--surface);
|
|
44
|
+
border: 1px solid var(--border);
|
|
45
|
+
border-radius: var(--radius);
|
|
46
|
+
box-shadow: var(--shadow);
|
|
47
|
+
padding: 1rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.rwo-stat-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
51
|
+
.rwo-stat-value { font-size: 28px; font-weight: 700; }
|
|
52
|
+
|
|
53
|
+
.rwo-detail {
|
|
54
|
+
background: var(--surface);
|
|
55
|
+
border: 1px solid var(--border);
|
|
56
|
+
border-radius: var(--radius);
|
|
57
|
+
box-shadow: var(--shadow);
|
|
58
|
+
padding: 1rem;
|
|
59
|
+
margin-bottom: 1rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.rwo-detail-grid {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
65
|
+
gap: 1rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.rwo-detail-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
69
|
+
.rwo-detail-value { font-size: 15px; }
|
|
70
|
+
|
|
71
|
+
.rwo-code-block {
|
|
72
|
+
background: var(--bg);
|
|
73
|
+
border: 1px solid var(--border);
|
|
74
|
+
border-radius: var(--radius);
|
|
75
|
+
padding: 1rem;
|
|
76
|
+
overflow-x: auto;
|
|
77
|
+
white-space: pre-wrap;
|
|
78
|
+
word-break: break-word;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.rwo-event {
|
|
82
|
+
background: var(--surface);
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
border-radius: var(--radius);
|
|
85
|
+
box-shadow: var(--shadow);
|
|
86
|
+
padding: 1rem;
|
|
87
|
+
margin-bottom: 1rem;
|
|
88
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.rwo-table {
|
|
2
|
+
width: 100%;
|
|
3
|
+
border-collapse: collapse;
|
|
4
|
+
background: var(--surface);
|
|
5
|
+
border: 1px solid var(--border);
|
|
6
|
+
border-radius: var(--radius);
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.rwo-table th, .rwo-table td {
|
|
11
|
+
text-align: left;
|
|
12
|
+
padding: 0.6rem 0.75rem;
|
|
13
|
+
border-bottom: 1px solid var(--border);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.rwo-table th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
17
|
+
|
|
18
|
+
.rwo-table tbody tr:last-child td { border-bottom: none; }
|
|
19
|
+
|
|
20
|
+
.rwo-path { font-weight: 600; }
|
|
21
|
+
|
|
22
|
+
.rwo-pagination {
|
|
23
|
+
display: flex;
|
|
24
|
+
justify-content: space-between;
|
|
25
|
+
margin-top: 1rem;
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.rwo-badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
padding: 0.2rem 0.6rem;
|
|
4
|
+
border-radius: 999px;
|
|
5
|
+
font-size: 12px;
|
|
6
|
+
font-weight: 600;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rwo-badge--delivered { background: var(--success-bg); color: var(--success-text); }
|
|
10
|
+
.rwo-badge--pending { background: var(--warning-bg); color: var(--warning-text); }
|
|
11
|
+
.rwo-badge--failed { background: var(--danger-bg); color: var(--danger-text); }
|
|
12
|
+
|
|
13
|
+
.rwo-alert {
|
|
14
|
+
background: var(--danger-bg);
|
|
15
|
+
color: var(--danger-text);
|
|
16
|
+
border-radius: var(--radius);
|
|
17
|
+
padding: 0.75rem 1rem;
|
|
18
|
+
margin-bottom: 1rem;
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.rwo-form { max-width: 480px; }
|
|
2
|
+
|
|
3
|
+
.rwo-field { margin-bottom: 1rem; }
|
|
4
|
+
|
|
5
|
+
.rwo-field label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 0.3rem; }
|
|
6
|
+
|
|
7
|
+
.rwo-input, .rwo-select {
|
|
8
|
+
width: 100%;
|
|
9
|
+
padding: 0.5rem 0.6rem;
|
|
10
|
+
border: 1px solid var(--border);
|
|
11
|
+
border-radius: var(--radius);
|
|
12
|
+
background: var(--surface);
|
|
13
|
+
color: var(--text);
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.rwo-checkbox-group { display: flex; flex-direction: column; gap: 0.4rem; }
|
|
18
|
+
.rwo-checkbox-label { display: flex; align-items: center; gap: 0.4rem; font-weight: 400; text-transform: none; letter-spacing: normal; }
|
|
19
|
+
|
|
20
|
+
.rwo-btn, .rwo-btn-small, .rwo-btn-danger, .rwo-btn-reset {
|
|
21
|
+
display: inline-block;
|
|
22
|
+
border: none;
|
|
23
|
+
border-radius: var(--radius);
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
font-size: 14px;
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
text-decoration: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.rwo-btn { background: var(--accent); color: var(--accent-text); padding: 0.55rem 1rem; }
|
|
31
|
+
.rwo-btn-small { background: var(--surface); color: var(--text); border: 1px solid var(--border); padding: 0.35rem 0.7rem; font-size: 12px; }
|
|
32
|
+
.rwo-btn-danger { background: var(--danger); color: #fff; padding: 0.55rem 1rem; }
|
|
33
|
+
.rwo-btn-reset { background: transparent; color: var(--muted); padding: 0.55rem 0.75rem; }
|
|
34
|
+
|
|
35
|
+
.rwo-secret {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 0.6rem;
|
|
39
|
+
margin-bottom: 0.5rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.rwo-secret-display { font-size: 14px; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.rwo-filters {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-wrap: wrap;
|
|
4
|
+
align-items: end;
|
|
5
|
+
gap: 1rem;
|
|
6
|
+
background: var(--surface);
|
|
7
|
+
border: 1px solid var(--border);
|
|
8
|
+
border-radius: var(--radius);
|
|
9
|
+
padding: 1rem;
|
|
10
|
+
margin-bottom: 1.5rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.rwo-filters .rwo-field { margin-bottom: 0; min-width: 140px; }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.rwo-timeline {
|
|
2
|
+
list-style: none;
|
|
3
|
+
margin-left: 6px;
|
|
4
|
+
border-left: 2px solid var(--border);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.rwo-timeline-item {
|
|
8
|
+
position: relative;
|
|
9
|
+
padding: 0 0 1rem 1.25rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.rwo-timeline-item::before {
|
|
13
|
+
content: "";
|
|
14
|
+
position: absolute;
|
|
15
|
+
left: -7px;
|
|
16
|
+
top: 4px;
|
|
17
|
+
width: 12px;
|
|
18
|
+
height: 12px;
|
|
19
|
+
border-radius: 50%;
|
|
20
|
+
border: 2px solid var(--bg);
|
|
21
|
+
background: var(--muted);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.rwo-timeline-item--delivered::before { background: var(--success-text); }
|
|
25
|
+
.rwo-timeline-item--failed::before { background: var(--danger); }
|
|
26
|
+
.rwo-timeline-item--scheduled::before { background: var(--warning-text); }
|
|
27
|
+
|
|
28
|
+
.rwo-timeline-title { font-weight: 600; }
|
|
29
|
+
|
|
30
|
+
.rwo-timeline-response { margin-top: 0.25rem; }
|
|
31
|
+
.rwo-timeline-response summary { cursor: pointer; }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class BaseController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
layout "rails_webhook_outbox/application"
|
|
5
|
+
helper RailsWebhookOutbox::ApplicationHelper
|
|
6
|
+
|
|
7
|
+
before_action :check_dashboard_enabled
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def check_dashboard_enabled
|
|
12
|
+
head :forbidden unless RailsWebhookOutbox.config.dashboard_enabled
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class DeliveriesController < BaseController
|
|
3
|
+
PER_PAGE = 50
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@status = params[:status] if Delivery.statuses.key?(params[:status])
|
|
7
|
+
@event = params[:event] if RailsWebhookOutbox.config.events.include?(params[:event])
|
|
8
|
+
@from = parse_date(params[:from])
|
|
9
|
+
@to = parse_date(params[:to])
|
|
10
|
+
@page = params[:page].to_i
|
|
11
|
+
@page = 1 if @page < 1
|
|
12
|
+
|
|
13
|
+
deliveries = Delivery.order(created_at: :desc)
|
|
14
|
+
deliveries = deliveries.where(status: @status) if @status
|
|
15
|
+
deliveries = deliveries.where(event: @event) if @event
|
|
16
|
+
deliveries = deliveries.where(created_at: @from..) if @from
|
|
17
|
+
deliveries = deliveries.where(created_at: ..@to.end_of_day) if @to
|
|
18
|
+
|
|
19
|
+
@total_count = deliveries.count
|
|
20
|
+
@deliveries = deliveries.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
@delivery = Delivery.find(params[:id])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def retry
|
|
28
|
+
@delivery = Delivery.find(params[:id])
|
|
29
|
+
@delivery.update!(status: :pending, next_retry_at: nil)
|
|
30
|
+
DeliveryJob.perform_later(@delivery)
|
|
31
|
+
|
|
32
|
+
respond_to do |format|
|
|
33
|
+
format.turbo_stream
|
|
34
|
+
format.html { redirect_to delivery_path(@delivery) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse_date(value)
|
|
41
|
+
Date.parse(value) if value.present?
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class EventsController < BaseController
|
|
3
|
+
def index
|
|
4
|
+
@events = RailsWebhookOutbox.config.events.map do |event|
|
|
5
|
+
{
|
|
6
|
+
name: event,
|
|
7
|
+
subscriber_count: Subscription.active.select { |s| s.subscribes_to?(event) }.size,
|
|
8
|
+
example: Delivery.where(event: event).order(created_at: :desc).first&.payload
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class SubscriptionsController < BaseController
|
|
3
|
+
def index
|
|
4
|
+
@subscriptions = Subscription.order(created_at: :desc)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@subscription = Subscription.find(params[:id])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
@subscription = Subscription.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
@subscription = Subscription.new(subscription_params)
|
|
17
|
+
|
|
18
|
+
if @subscription.save
|
|
19
|
+
redirect_to subscription_path(@subscription)
|
|
20
|
+
else
|
|
21
|
+
render :new, status: 422
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def edit
|
|
26
|
+
@subscription = Subscription.find(params[:id])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update
|
|
30
|
+
@subscription = Subscription.find(params[:id])
|
|
31
|
+
|
|
32
|
+
if @subscription.update(subscription_params)
|
|
33
|
+
redirect_to subscription_path(@subscription)
|
|
34
|
+
else
|
|
35
|
+
render :edit, status: 422
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rotate_secret
|
|
40
|
+
@subscription = Subscription.find(params[:id])
|
|
41
|
+
@subscription.rotate_secret!
|
|
42
|
+
redirect_to subscription_path(@subscription)
|
|
43
|
+
rescue RailsWebhookOutbox::SecretRotationError
|
|
44
|
+
redirect_to subscription_path(@subscription, rotation_error: true)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def subscription_params
|
|
50
|
+
permitted = params.require(:subscription).permit(:url, :description, :active, events: [])
|
|
51
|
+
raw_events = params[:subscription][:events]
|
|
52
|
+
|
|
53
|
+
if raw_events.is_a?(String)
|
|
54
|
+
permitted[:events] = raw_events.split(",").map(&:strip).reject(&:empty?)
|
|
55
|
+
elsif permitted.key?(:events)
|
|
56
|
+
permitted[:events] = Array(permitted[:events]).map(&:to_s).map(&:strip).reject(&:empty?)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
permitted
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|