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
@@ -0,0 +1,29 @@
1
+ module RailsWebhookOutbox
2
+ module ApplicationHelper
3
+ def inline_styles
4
+ dir = RailsWebhookOutbox::Engine.root.join("app/assets/stylesheets/rails_webhook_outbox")
5
+ css = dir.glob("_*.css").sort.map(&:read).join("\n")
6
+ content_tag(:style, css.html_safe)
7
+ end
8
+
9
+ def status_badge(status)
10
+ css_class = case status.to_s
11
+ when "delivered" then "rwo-badge--delivered"
12
+ when "failed" then "rwo-badge--failed"
13
+ else "rwo-badge--pending"
14
+ end
15
+ content_tag(:span, status.to_s.capitalize, class: "rwo-badge #{css_class}")
16
+ end
17
+
18
+ def active_badge(active)
19
+ css_class = active ? "rwo-badge--delivered" : "rwo-badge--failed"
20
+ content_tag(:span, active ? "Active" : "Disabled", class: "rwo-badge #{css_class}")
21
+ end
22
+
23
+ def masked_secret(secret)
24
+ return "" if secret.blank?
25
+
26
+ "#{"•" * 8}#{secret.last(4)}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,2 @@
1
+ import "@hotwired/turbo"
2
+ import "rails_webhook_outbox/controllers"
@@ -0,0 +1,4 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+ export { application }
@@ -0,0 +1,4 @@
1
+ import { application } from "rails_webhook_outbox/controllers/application"
2
+ import SecretController from "rails_webhook_outbox/controllers/secret_controller"
3
+
4
+ application.register("secret", SecretController)
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["display", "revealButton", "copiedHint"]
5
+ static values = { value: String }
6
+
7
+ connect() {
8
+ this.masked = this.displayTarget.textContent
9
+ this.revealed = false
10
+ }
11
+
12
+ reveal() {
13
+ this.revealed = !this.revealed
14
+ this.displayTarget.textContent = this.revealed ? this.valueValue : this.masked
15
+ this.revealButtonTarget.textContent = this.revealed ? "Hide" : "Reveal"
16
+ }
17
+
18
+ copy() {
19
+ navigator.clipboard.writeText(this.valueValue).then(() => {
20
+ this.copiedHintTarget.hidden = false
21
+ setTimeout(() => { this.copiedHintTarget.hidden = true }, 1500)
22
+ })
23
+ }
24
+ }
@@ -7,6 +7,7 @@ module RailsWebhookOutbox
7
7
  return skip_disabled_subscription(delivery) unless delivery.subscription.active?
8
8
 
9
9
  if RailsWebhookOutbox.config.test_mode
10
+ record_attempt(delivery, outcome: :delivered, duration_ms: 0)
10
11
  delivery.update!(status: :delivered, attempts: delivery.attempts + 1, delivered_at: Time.current)
11
12
  delivery.subscription.record_delivery_success!
12
13
  notify("webhook.delivered.rails_webhook_outbox", delivery, 0)
@@ -16,6 +17,8 @@ module RailsWebhookOutbox
16
17
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
18
  response = Sender.call(delivery)
18
19
  duration_ms = elapsed_ms(start)
20
+ record_attempt(delivery, outcome: :delivered, response_code: response.code.to_i,
21
+ response_body: response.body.truncate(1000), duration_ms: duration_ms)
19
22
  delivery.update!(
20
23
  status: :delivered,
21
24
  response_code: response.code.to_i,
@@ -30,6 +33,8 @@ module RailsWebhookOutbox
30
33
  duration_ms = elapsed_ms(start)
31
34
  max_retries = RailsWebhookOutbox.config.max_retries
32
35
  final = executions >= max_retries
36
+ record_attempt(delivery, outcome: :failed, response_code: e.response_code,
37
+ response_body: e.response_body&.truncate(1000), duration_ms: duration_ms)
33
38
  delivery.update!(
34
39
  response_code: e.response_code,
35
40
  response_body: e.response_body&.truncate(1000),
@@ -54,6 +59,16 @@ module RailsWebhookOutbox
54
59
  Rails.logger.warn { "[RailsWebhookOutbox] skipped event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} reason=subscription_inactive" }
55
60
  end
56
61
 
62
+ def record_attempt(delivery, outcome:, duration_ms:, response_code: nil, response_body: nil)
63
+ delivery.delivery_attempts.create!(
64
+ attempt_number: delivery.attempts + 1,
65
+ outcome: outcome,
66
+ response_code: response_code,
67
+ response_body: response_body,
68
+ duration_ms: duration_ms
69
+ )
70
+ end
71
+
57
72
  def elapsed_ms(start)
58
73
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
59
74
  end
@@ -1,8 +1,12 @@
1
1
  module RailsWebhookOutbox
2
2
  class Delivery < ApplicationRecord
3
+ include ActionView::RecordIdentifier
4
+
3
5
  self.table_name = "webhook_outbox_deliveries"
4
6
 
5
7
  belongs_to :subscription
8
+ # Not `attempts`: that name is the integer counter column on this table.
9
+ has_many :delivery_attempts, -> { order(:attempt_number) }, dependent: :delete_all
6
10
 
7
11
  enum :status, { pending: 0, delivered: 1, failed: 2 }
8
12
 
@@ -14,10 +18,34 @@ module RailsWebhookOutbox
14
18
 
15
19
  scope :retryable, -> { pending }
16
20
 
21
+ after_update_commit :broadcast_row_update
22
+ after_update_commit :broadcast_detail_update
23
+ after_commit :broadcast_overview_update
24
+
17
25
  private
18
26
 
19
27
  def generate_idempotency_key
20
28
  self.idempotency_key ||= SecureRandom.uuid
21
29
  end
30
+
31
+ def broadcast_row_update
32
+ broadcast_replace_to "rails_webhook_outbox_deliveries", html: render_partial("rails_webhook_outbox/deliveries/row")
33
+ end
34
+
35
+ def broadcast_detail_update
36
+ broadcast_replace_to self, target: dom_id(self, :detail), html: render_partial("rails_webhook_outbox/deliveries/detail")
37
+ end
38
+
39
+ # Rendered through the engine's own controller: Turbo's default (the host app's
40
+ # ApplicationController) renders blank in an API-only host and has no access to
41
+ # the engine's route helpers.
42
+ def render_partial(partial)
43
+ BaseController.render(partial: partial, locals: { delivery: self }, formats: [:html])
44
+ end
45
+
46
+ def broadcast_overview_update
47
+ Overview.broadcast_stats
48
+ Overview.broadcast_recent_failures
49
+ end
22
50
  end
23
51
  end
@@ -0,0 +1,13 @@
1
+ module RailsWebhookOutbox
2
+ # One HTTP attempt to deliver a webhook. Written by DeliveryJob so the dashboard
3
+ # can show the full attempt history, not just the latest result on the Delivery.
4
+ class DeliveryAttempt < ApplicationRecord
5
+ self.table_name = "webhook_outbox_delivery_attempts"
6
+
7
+ belongs_to :delivery
8
+
9
+ enum :outcome, { delivered: 0, failed: 1 }
10
+
11
+ validates :attempt_number, presence: true
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ module RailsWebhookOutbox
2
+ # Read model behind the dashboard overview page. Shared by OverviewController
3
+ # and the Turbo Stream broadcasts so the page and its live updates can't drift.
4
+ class Overview
5
+ STREAM = "rails_webhook_outbox_overview".freeze
6
+ STATS_TARGET = "rwo-overview-stats".freeze
7
+ FAILURES_TARGET = "rwo-overview-failures".freeze
8
+ RECENT_FAILURES_LIMIT = 10
9
+
10
+ def self.broadcast_stats
11
+ broadcast_replace(STATS_TARGET, "rails_webhook_outbox/overview/stats")
12
+ end
13
+
14
+ def self.broadcast_recent_failures
15
+ broadcast_replace(FAILURES_TARGET, "rails_webhook_outbox/overview/recent_failures")
16
+ end
17
+
18
+ # Rendered through the engine's own controller: the host app's ApplicationController
19
+ # (which Turbo defaults to) may be API-only and renders partials blank, and it has no
20
+ # access to the engine's route helpers.
21
+ def self.broadcast_replace(target, partial)
22
+ html = BaseController.render(partial: partial, locals: { overview: new }, formats: [:html])
23
+ Turbo::StreamsChannel.broadcast_replace_to(STREAM, target: target, html: html)
24
+ end
25
+ private_class_method :broadcast_replace
26
+
27
+ def subscription_count
28
+ Subscription.count
29
+ end
30
+
31
+ def active_subscription_count
32
+ Subscription.active.count
33
+ end
34
+
35
+ def delivery_count(status)
36
+ delivery_counts.fetch(status.to_s, 0)
37
+ end
38
+
39
+ def recent_failures
40
+ Delivery.failed.includes(:subscription).order(created_at: :desc).limit(RECENT_FAILURES_LIMIT)
41
+ end
42
+
43
+ private
44
+
45
+ def delivery_counts
46
+ @delivery_counts ||= Delivery.group(:status).count
47
+ end
48
+ end
49
+ end
@@ -2,12 +2,13 @@ module RailsWebhookOutbox
2
2
  class Subscription < ApplicationRecord
3
3
  self.table_name = "webhook_outbox_subscriptions"
4
4
 
5
- URL_FORMAT = /\Ahttps?:\/\/.+/i
5
+ URL_FORMAT = /\Ahttps?:\/\/.+\z/i
6
6
 
7
7
  attribute :active, :boolean, default: true
8
8
 
9
9
  before_validation :generate_secret, on: :create
10
10
  before_save :reset_consecutive_failures_on_reactivation
11
+ after_commit :broadcast_overview_stats
11
12
 
12
13
  validates :url, presence: true, format: { with: URL_FORMAT, message: "must be a valid HTTP or HTTPS URL" }
13
14
  validates :secret, presence: true
@@ -66,5 +67,9 @@ module RailsWebhookOutbox
66
67
  def reset_consecutive_failures_on_reactivation
67
68
  self.consecutive_failures = 0 if active_changed?(from: false, to: true)
68
69
  end
70
+
71
+ def broadcast_overview_stats
72
+ Overview.broadcast_stats
73
+ end
69
74
  end
70
75
  end
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RailsWebhookOutbox</title>
7
+ <link rel="icon" href="data:,">
8
+ <%= inline_styles %>
9
+ <%= javascript_importmap_tags "rails_webhook_outbox" %>
10
+ </head>
11
+ <body>
12
+ <nav class="rwo-nav">
13
+ <%= link_to "Overview", root_path, class: "rwo-nav-link" %>
14
+ <%= link_to "Subscriptions", subscriptions_path, class: "rwo-nav-link" %>
15
+ <%= link_to "Deliveries", deliveries_path, class: "rwo-nav-link" %>
16
+ <%= link_to "Events", events_path, class: "rwo-nav-link" %>
17
+ </nav>
18
+ <main class="rwo-main">
19
+ <%= yield %>
20
+ </main>
21
+ </body>
22
+ </html>
@@ -0,0 +1,47 @@
1
+ <div id="<%= dom_id(delivery, :detail) %>">
2
+ <div class="rwo-header rwo-header-row">
3
+ <h1><%= delivery.event %></h1>
4
+ <%= status_badge(delivery.status) %>
5
+ </div>
6
+
7
+ <div class="rwo-detail-grid">
8
+ <div class="rwo-detail-item">
9
+ <div class="rwo-detail-label">Subscription</div>
10
+ <div class="rwo-detail-value"><%= link_to delivery.subscription.url, subscription_path(delivery.subscription) %></div>
11
+ </div>
12
+ <div class="rwo-detail-item">
13
+ <div class="rwo-detail-label">Attempts</div>
14
+ <div class="rwo-detail-value"><%= delivery.attempts %></div>
15
+ </div>
16
+ <div class="rwo-detail-item">
17
+ <div class="rwo-detail-label">Response Code</div>
18
+ <div class="rwo-detail-value"><%= delivery.response_code || "—" %></div>
19
+ </div>
20
+ <div class="rwo-detail-item">
21
+ <div class="rwo-detail-label">Delivered At</div>
22
+ <div class="rwo-detail-value"><%= delivery.delivered_at&.strftime("%Y-%m-%d %H:%M:%S") || "—" %></div>
23
+ </div>
24
+ <div class="rwo-detail-item">
25
+ <div class="rwo-detail-label">Next Retry At</div>
26
+ <div class="rwo-detail-value"><%= delivery.next_retry_at&.strftime("%Y-%m-%d %H:%M:%S") || "—" %></div>
27
+ </div>
28
+ <div class="rwo-detail-item">
29
+ <div class="rwo-detail-label">Created At</div>
30
+ <div class="rwo-detail-value"><%= delivery.created_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
31
+ </div>
32
+ </div>
33
+
34
+ <%= render "rails_webhook_outbox/deliveries/timeline", delivery: delivery %>
35
+
36
+ <h2 class="rwo-section-title">Payload</h2>
37
+ <pre class="rwo-monospace rwo-code-block"><%= JSON.pretty_generate(delivery.payload) %></pre>
38
+
39
+ <% if delivery.response_body.present? %>
40
+ <h2 class="rwo-section-title">Response Body</h2>
41
+ <pre class="rwo-monospace rwo-code-block"><%= delivery.response_body %></pre>
42
+ <% end %>
43
+
44
+ <% if delivery.failed? %>
45
+ <%= button_to "Retry Delivery", retry_delivery_path(delivery), class: "rwo-btn" %>
46
+ <% end %>
47
+ </div>
@@ -0,0 +1,7 @@
1
+ <tr id="<%= dom_id(delivery) %>">
2
+ <td><%= link_to delivery.event, delivery_path(delivery), class: "rwo-path" %></td>
3
+ <td class="rwo-muted"><%= delivery.subscription.url %></td>
4
+ <td><%= status_badge(delivery.status) %></td>
5
+ <td class="rwo-muted"><%= delivery.attempts %></td>
6
+ <td class="rwo-muted"><%= delivery.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
7
+ </tr>
@@ -0,0 +1,41 @@
1
+ <% attempts = delivery.delivery_attempts.to_a %>
2
+
3
+ <h2 class="rwo-section-title">Timeline</h2>
4
+
5
+ <ol class="rwo-timeline">
6
+ <li class="rwo-timeline-item">
7
+ <div class="rwo-timeline-title">Queued</div>
8
+ <div class="rwo-muted"><%= delivery.created_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
9
+ </li>
10
+
11
+ <% attempts.each do |attempt| %>
12
+ <li class="rwo-timeline-item rwo-timeline-item--<%= attempt.outcome %>">
13
+ <div class="rwo-timeline-title">
14
+ Attempt <%= attempt.attempt_number %>
15
+ <%= status_badge(attempt.outcome) %>
16
+ </div>
17
+ <div class="rwo-muted">
18
+ <%= attempt.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
19
+ &middot; HTTP <%= attempt.response_code || "—" %>
20
+ <% if attempt.duration_ms %>&middot; <%= attempt.duration_ms %> ms<% end %>
21
+ </div>
22
+ <% if attempt.response_body.present? %>
23
+ <details class="rwo-timeline-response">
24
+ <summary class="rwo-muted">Response body</summary>
25
+ <pre class="rwo-monospace rwo-code-block"><%= attempt.response_body %></pre>
26
+ </details>
27
+ <% end %>
28
+ </li>
29
+ <% end %>
30
+
31
+ <% if delivery.pending? && delivery.next_retry_at %>
32
+ <li class="rwo-timeline-item rwo-timeline-item--scheduled">
33
+ <div class="rwo-timeline-title">Retry scheduled</div>
34
+ <div class="rwo-muted"><%= delivery.next_retry_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
35
+ </li>
36
+ <% end %>
37
+ </ol>
38
+
39
+ <% if attempts.empty? && delivery.attempts.positive? %>
40
+ <p class="rwo-muted">Attempt history is recorded from version 0.4.0; this delivery was attempted before then.</p>
41
+ <% end %>
@@ -0,0 +1,62 @@
1
+ <div class="rwo-header">
2
+ <h1>Deliveries</h1>
3
+ </div>
4
+
5
+ <%= form_with url: deliveries_path, method: :get, local: true, class: "rwo-filters" do %>
6
+ <div class="rwo-field">
7
+ <%= label_tag :status, "Status" %>
8
+ <%= select_tag :status,
9
+ options_for_select([["All", ""]] + RailsWebhookOutbox::Delivery.statuses.keys.map { |s| [s.capitalize, s] }, @status),
10
+ class: "rwo-select" %>
11
+ </div>
12
+
13
+ <div class="rwo-field">
14
+ <%= label_tag :event, "Event" %>
15
+ <%= select_tag :event,
16
+ options_for_select([["All", ""]] + RailsWebhookOutbox.config.events, @event),
17
+ class: "rwo-select" %>
18
+ </div>
19
+
20
+ <div class="rwo-field">
21
+ <%= label_tag :from, "From" %>
22
+ <%= date_field_tag :from, @from, class: "rwo-input" %>
23
+ </div>
24
+
25
+ <div class="rwo-field">
26
+ <%= label_tag :to, "To" %>
27
+ <%= date_field_tag :to, @to, class: "rwo-input" %>
28
+ </div>
29
+
30
+ <%= submit_tag "Filter", class: "rwo-btn" %>
31
+ <%= link_to "Reset", deliveries_path, class: "rwo-btn-reset" %>
32
+ <% end %>
33
+
34
+ <%= turbo_stream_from "rails_webhook_outbox_deliveries" %>
35
+
36
+ <% if @deliveries.empty? %>
37
+ <p class="rwo-empty">No deliveries found.</p>
38
+ <% else %>
39
+ <table class="rwo-table">
40
+ <thead>
41
+ <tr>
42
+ <th>Event</th>
43
+ <th>Subscription</th>
44
+ <th>Status</th>
45
+ <th>Attempts</th>
46
+ <th>Created</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ <%= render partial: "row", collection: @deliveries, as: :delivery %>
51
+ </tbody>
52
+ </table>
53
+
54
+ <div class="rwo-pagination">
55
+ <% if @page > 1 %>
56
+ <%= link_to "← Previous", deliveries_path(request.query_parameters.merge(page: @page - 1)), class: "rwo-btn-small" %>
57
+ <% end %>
58
+ <% if @page * RailsWebhookOutbox::DeliveriesController::PER_PAGE < @total_count %>
59
+ <%= link_to "Next →", deliveries_path(request.query_parameters.merge(page: @page + 1)), class: "rwo-btn-small" %>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <%= turbo_stream.replace dom_id(@delivery), partial: "rails_webhook_outbox/deliveries/row", locals: { delivery: @delivery } %>
2
+ <%= turbo_stream.replace dom_id(@delivery, :detail), partial: "rails_webhook_outbox/deliveries/detail", locals: { delivery: @delivery } %>
@@ -0,0 +1,5 @@
1
+ <%= link_to "← All Deliveries", deliveries_path, class: "rwo-back" %>
2
+
3
+ <%= turbo_stream_from @delivery %>
4
+
5
+ <%= render "detail", delivery: @delivery %>
@@ -0,0 +1,22 @@
1
+ <div class="rwo-header">
2
+ <h1>Registered Events</h1>
3
+ </div>
4
+
5
+ <% if @events.empty? %>
6
+ <p class="rwo-empty">No events registered. Set <code>config.events</code> in your initializer.</p>
7
+ <% else %>
8
+ <% @events.each do |event| %>
9
+ <div class="rwo-event">
10
+ <div class="rwo-header-row">
11
+ <h2 class="rwo-section-title"><%= event[:name] %></h2>
12
+ <span class="rwo-muted"><%= event[:subscriber_count] %> active subscriber<%= "s" unless event[:subscriber_count] == 1 %></span>
13
+ </div>
14
+
15
+ <% if event[:example] %>
16
+ <pre class="rwo-monospace rwo-code-block"><%= JSON.pretty_generate(event[:example]) %></pre>
17
+ <% else %>
18
+ <p class="rwo-muted">No deliveries yet for this event.</p>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
22
+ <% end %>
@@ -0,0 +1,29 @@
1
+ <div id="<%= RailsWebhookOutbox::Overview::FAILURES_TARGET %>">
2
+ <h2 class="rwo-section-title">Recent Failures</h2>
3
+
4
+ <% failures = overview.recent_failures %>
5
+ <% if failures.empty? %>
6
+ <p class="rwo-empty">No failed deliveries.</p>
7
+ <% else %>
8
+ <table class="rwo-table">
9
+ <thead>
10
+ <tr>
11
+ <th>Event</th>
12
+ <th>Subscription</th>
13
+ <th>Attempts</th>
14
+ <th>Created</th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ <% failures.each do |delivery| %>
19
+ <tr>
20
+ <td><%= link_to delivery.event, delivery_path(delivery) %></td>
21
+ <td><%= delivery.subscription.url %></td>
22
+ <td class="rwo-muted"><%= delivery.attempts %></td>
23
+ <td class="rwo-muted"><%= delivery.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ <% end %>
29
+ </div>
@@ -0,0 +1,19 @@
1
+ <div id="<%= RailsWebhookOutbox::Overview::STATS_TARGET %>" class="rwo-stats-grid">
2
+ <div class="rwo-stat">
3
+ <div class="rwo-stat-label">Subscriptions</div>
4
+ <div class="rwo-stat-value"><%= link_to overview.subscription_count, subscriptions_path %></div>
5
+ <div class="rwo-muted"><%= overview.active_subscription_count %> active</div>
6
+ </div>
7
+ <div class="rwo-stat">
8
+ <div class="rwo-stat-label">Delivered</div>
9
+ <div class="rwo-stat-value"><%= link_to overview.delivery_count(:delivered), deliveries_path(status: "delivered") %></div>
10
+ </div>
11
+ <div class="rwo-stat">
12
+ <div class="rwo-stat-label">Pending</div>
13
+ <div class="rwo-stat-value"><%= link_to overview.delivery_count(:pending), deliveries_path(status: "pending") %></div>
14
+ </div>
15
+ <div class="rwo-stat">
16
+ <div class="rwo-stat-label">Failed</div>
17
+ <div class="rwo-stat-value"><%= link_to overview.delivery_count(:failed), deliveries_path(status: "failed") %></div>
18
+ </div>
19
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="rwo-header">
2
+ <h1>Overview</h1>
3
+ </div>
4
+
5
+ <%= turbo_stream_from RailsWebhookOutbox::Overview::STREAM %>
6
+
7
+ <%= render "stats", overview: @overview %>
8
+ <%= render "recent_failures", overview: @overview %>
@@ -0,0 +1,49 @@
1
+ <%= form_with model: subscription, url: subscription.persisted? ? subscription_path(subscription) : subscriptions_path, local: true, class: "rwo-form" do |form| %>
2
+ <% if subscription.errors.any? %>
3
+ <div class="rwo-alert">
4
+ <ul>
5
+ <% subscription.errors.full_messages.each do |message| %>
6
+ <li><%= message %></li>
7
+ <% end %>
8
+ </ul>
9
+ </div>
10
+ <% end %>
11
+
12
+ <div class="rwo-field">
13
+ <%= form.label :url %>
14
+ <%= form.text_field :url, class: "rwo-input", placeholder: "https://example.com/webhooks" %>
15
+ </div>
16
+
17
+ <div class="rwo-field">
18
+ <%= form.label :events %>
19
+ <% if RailsWebhookOutbox.config.events.any? %>
20
+ <div class="rwo-checkbox-group">
21
+ <%= hidden_field_tag "subscription[events][]", "", id: nil %>
22
+ <% RailsWebhookOutbox.config.events.each do |event| %>
23
+ <label class="rwo-checkbox-label">
24
+ <%= check_box_tag "subscription[events][]", event, subscription.events.include?(event), id: nil %>
25
+ <%= event %>
26
+ </label>
27
+ <% end %>
28
+ </div>
29
+ <% else %>
30
+ <%= form.text_field :events, name: "subscription[events]", value: subscription.events.join(", "), class: "rwo-input", placeholder: "order.created, order.updated" %>
31
+ <% end %>
32
+ </div>
33
+
34
+ <div class="rwo-field">
35
+ <%= form.label :description %>
36
+ <%= form.text_field :description, class: "rwo-input" %>
37
+ </div>
38
+
39
+ <% if subscription.persisted? %>
40
+ <div class="rwo-field">
41
+ <label class="rwo-checkbox-label">
42
+ <%= form.check_box :active %>
43
+ Active
44
+ </label>
45
+ </div>
46
+ <% end %>
47
+
48
+ <%= form.submit subscription.persisted? ? "Update Subscription" : "Create Subscription", class: "rwo-btn" %>
49
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= link_to "← Back", subscription_path(@subscription), class: "rwo-back" %>
2
+
3
+ <div class="rwo-header">
4
+ <h1>Edit Subscription</h1>
5
+ </div>
6
+
7
+ <%= render "form", subscription: @subscription %>
@@ -0,0 +1,37 @@
1
+ <div class="rwo-header rwo-header-row">
2
+ <h1>Subscriptions</h1>
3
+ <%= link_to "New Subscription", new_subscription_path, class: "rwo-btn" %>
4
+ </div>
5
+
6
+ <% if @subscriptions.empty? %>
7
+ <p class="rwo-empty">No subscriptions yet.</p>
8
+ <% else %>
9
+ <table class="rwo-table">
10
+ <thead>
11
+ <tr>
12
+ <th>URL</th>
13
+ <th>Events</th>
14
+ <th>Status</th>
15
+ <th>Failures</th>
16
+ <th></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @subscriptions.each do |subscription| %>
21
+ <tr>
22
+ <td><%= link_to subscription.url, subscription_path(subscription), class: "rwo-path" %></td>
23
+ <td class="rwo-muted"><%= subscription.events.join(", ") %></td>
24
+ <td><%= active_badge(subscription.active?) %></td>
25
+ <td class="rwo-muted"><%= subscription.consecutive_failures %></td>
26
+ <td>
27
+ <%= button_to(subscription.active? ? "Disable" : "Enable",
28
+ subscription_path(subscription),
29
+ method: :patch,
30
+ params: { subscription: { active: !subscription.active? } },
31
+ class: "rwo-btn-small") %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= link_to "← All Subscriptions", subscriptions_path, class: "rwo-back" %>
2
+
3
+ <div class="rwo-header">
4
+ <h1>New Subscription</h1>
5
+ </div>
6
+
7
+ <%= render "form", subscription: @subscription %>