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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1
  3. data/Rakefile +6 -1
  4. data/UPGRADING.md +92 -0
  5. data/app/assets/stylesheets/rails_webhook_outbox/_01_base.css +53 -0
  6. data/app/assets/stylesheets/rails_webhook_outbox/_02_layout.css +88 -0
  7. data/app/assets/stylesheets/rails_webhook_outbox/_03_table.css +26 -0
  8. data/app/assets/stylesheets/rails_webhook_outbox/_04_badges.css +19 -0
  9. data/app/assets/stylesheets/rails_webhook_outbox/_05_forms.css +42 -0
  10. data/app/assets/stylesheets/rails_webhook_outbox/_06_filters.css +13 -0
  11. data/app/assets/stylesheets/rails_webhook_outbox/_07_timeline.css +31 -0
  12. data/app/controllers/rails_webhook_outbox/base_controller.rb +15 -0
  13. data/app/controllers/rails_webhook_outbox/deliveries_controller.rb +46 -0
  14. data/app/controllers/rails_webhook_outbox/events_controller.rb +13 -0
  15. data/app/controllers/rails_webhook_outbox/overview_controller.rb +7 -0
  16. data/app/controllers/rails_webhook_outbox/subscriptions_controller.rb +62 -0
  17. data/app/helpers/rails_webhook_outbox/application_helper.rb +29 -0
  18. data/app/javascript/rails_webhook_outbox/application.js +2 -0
  19. data/app/javascript/rails_webhook_outbox/controllers/application.js +4 -0
  20. data/app/javascript/rails_webhook_outbox/controllers/index.js +4 -0
  21. data/app/javascript/rails_webhook_outbox/controllers/secret_controller.js +24 -0
  22. data/app/jobs/rails_webhook_outbox/delivery_job.rb +15 -0
  23. data/app/models/rails_webhook_outbox/delivery.rb +28 -0
  24. data/app/models/rails_webhook_outbox/delivery_attempt.rb +13 -0
  25. data/app/models/rails_webhook_outbox/overview.rb +49 -0
  26. data/app/models/rails_webhook_outbox/subscription.rb +6 -1
  27. data/app/views/layouts/rails_webhook_outbox/application.html.erb +22 -0
  28. data/app/views/rails_webhook_outbox/deliveries/_detail.html.erb +47 -0
  29. data/app/views/rails_webhook_outbox/deliveries/_row.html.erb +7 -0
  30. data/app/views/rails_webhook_outbox/deliveries/_timeline.html.erb +41 -0
  31. data/app/views/rails_webhook_outbox/deliveries/index.html.erb +62 -0
  32. data/app/views/rails_webhook_outbox/deliveries/retry.turbo_stream.erb +2 -0
  33. data/app/views/rails_webhook_outbox/deliveries/show.html.erb +5 -0
  34. data/app/views/rails_webhook_outbox/events/index.html.erb +22 -0
  35. data/app/views/rails_webhook_outbox/overview/_recent_failures.html.erb +29 -0
  36. data/app/views/rails_webhook_outbox/overview/_stats.html.erb +19 -0
  37. data/app/views/rails_webhook_outbox/overview/show.html.erb +8 -0
  38. data/app/views/rails_webhook_outbox/subscriptions/_form.html.erb +49 -0
  39. data/app/views/rails_webhook_outbox/subscriptions/edit.html.erb +7 -0
  40. data/app/views/rails_webhook_outbox/subscriptions/index.html.erb +37 -0
  41. data/app/views/rails_webhook_outbox/subscriptions/new.html.erb +7 -0
  42. data/app/views/rails_webhook_outbox/subscriptions/show.html.erb +49 -0
  43. data/config/importmap.rb +6 -0
  44. data/config/routes.rb +17 -0
  45. data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +1 -1
  46. data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +4 -4
  47. data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +2 -2
  48. data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +2 -2
  49. data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +1 -1
  50. data/db/migrate/20261027000001_create_webhook_outbox_delivery_attempts.rb +16 -0
  51. data/lib/generators/rails_webhook_outbox/install/install_generator.rb +1 -19
  52. data/lib/rails_webhook_outbox/configuration.rb +3 -1
  53. data/lib/rails_webhook_outbox/engine.rb +16 -0
  54. data/lib/rails_webhook_outbox/version.rb +1 -1
  55. metadata +67 -3
  56. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
  57. 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: 72234fe9faabd82b982170783792dd9d50188b8a727b72f46ab30e5f8d36929a
4
- data.tar.gz: bf7c3b453aaf3c66d1ce4a06fb2a72d169dfcae330cc3ba8819dbe75de020e67
3
+ metadata.gz: b4c853b16c9d48f970ebe2ccb83c7de3e1d6789d93a9e78407f788b6a5f8dd8d
4
+ data.tar.gz: a9a380da628a0f00c0d6d5ac08d2351edbc1e0fc3084b4d73ce6dab7e1f28c44
5
5
  SHA512:
6
- metadata.gz: 6363ae17aa5037b3c58e8169d97c7bb6c997f5e8e515a963aa5964f9aba9ffe29c4e09805f1bcd69e69f569e7887b2e1f7c69119c93c25f8004258809ab6581e
7
- data.tar.gz: 7009433848a306ff1465d73d526a9378230ca7730fa7a9bcc4aa3159091bdaab663f442e2124ff3b6507a9fb63adb65bda814607a3d56fb297ffc04b5018cc25
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
- task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :spec]
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,7 @@
1
+ module RailsWebhookOutbox
2
+ class OverviewController < BaseController
3
+ def show
4
+ @overview = Overview.new
5
+ end
6
+ end
7
+ 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