rails_webhook_outbox 0.2.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +218 -4
  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 +62 -1
  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 +50 -2
  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 +6 -0
  49. data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
  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 +14 -1
  53. data/lib/rails_webhook_outbox/engine.rb +16 -0
  54. data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
  55. data/lib/rails_webhook_outbox/sender.rb +2 -1
  56. data/lib/rails_webhook_outbox/signature.rb +2 -2
  57. data/lib/rails_webhook_outbox/version.rb +1 -1
  58. data/lib/rails_webhook_outbox.rb +1 -0
  59. data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
  60. metadata +70 -3
  61. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
  62. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +0 -13
@@ -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
@@ -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
+ }
@@ -4,12 +4,21 @@ module RailsWebhookOutbox
4
4
  retry_on RailsWebhookOutbox::DeliveryError, wait: :polynomially_longer, attempts: :unlimited
5
5
 
6
6
  def perform(delivery)
7
+ return skip_disabled_subscription(delivery) unless delivery.subscription.active?
8
+
7
9
  if RailsWebhookOutbox.config.test_mode
10
+ record_attempt(delivery, outcome: :delivered, duration_ms: 0)
8
11
  delivery.update!(status: :delivered, attempts: delivery.attempts + 1, delivered_at: Time.current)
12
+ delivery.subscription.record_delivery_success!
13
+ notify("webhook.delivered.rails_webhook_outbox", delivery, 0)
9
14
  return
10
15
  end
11
16
 
17
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
12
18
  response = Sender.call(delivery)
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)
13
22
  delivery.update!(
14
23
  status: :delivered,
15
24
  response_code: response.code.to_i,
@@ -17,9 +26,15 @@ module RailsWebhookOutbox
17
26
  delivered_at: Time.current,
18
27
  attempts: delivery.attempts + 1
19
28
  )
29
+ delivery.subscription.record_delivery_success!
30
+ Rails.logger.info { "[RailsWebhookOutbox] delivered event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{response.code} duration=#{duration_ms}ms" }
31
+ notify("webhook.delivered.rails_webhook_outbox", delivery, duration_ms)
20
32
  rescue DeliveryError => e
33
+ duration_ms = elapsed_ms(start)
21
34
  max_retries = RailsWebhookOutbox.config.max_retries
22
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)
23
38
  delivery.update!(
24
39
  response_code: e.response_code,
25
40
  response_body: e.response_body&.truncate(1000),
@@ -27,7 +42,53 @@ module RailsWebhookOutbox
27
42
  status: final ? :failed : :pending,
28
43
  next_retry_at: final ? nil : Time.current + ((executions**4) + 2).seconds
29
44
  )
30
- raise unless final
45
+ if final
46
+ Rails.logger.error { "[RailsWebhookOutbox] failed event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{e.response_code} attempts=#{delivery.attempts}" }
47
+ notify("webhook.failed.rails_webhook_outbox", delivery, duration_ms)
48
+ record_failure_and_notify_if_tripped(delivery)
49
+ else
50
+ Rails.logger.warn { "[RailsWebhookOutbox] retry event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{e.response_code} attempt=#{delivery.attempts} next_retry_at=#{delivery.next_retry_at.utc.iso8601}" }
51
+ raise
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def skip_disabled_subscription(delivery)
58
+ delivery.update!(status: :failed, next_retry_at: nil)
59
+ Rails.logger.warn { "[RailsWebhookOutbox] skipped event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} reason=subscription_inactive" }
60
+ end
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
+
72
+ def elapsed_ms(start)
73
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
74
+ end
75
+
76
+ def notify(event_name, delivery, duration_ms)
77
+ ActiveSupport::Notifications.instrument(event_name,
78
+ event: delivery.event,
79
+ subscription_id: delivery.subscription_id,
80
+ delivery_id: delivery.id,
81
+ duration: duration_ms)
82
+ end
83
+
84
+ def record_failure_and_notify_if_tripped(delivery)
85
+ return unless delivery.subscription.record_delivery_failure!
86
+
87
+ subscription = delivery.subscription
88
+ Rails.logger.warn { "[RailsWebhookOutbox] circuit_breaker_tripped subscription_id=#{subscription.id} consecutive_failures=#{subscription.consecutive_failures}" }
89
+ ActiveSupport::Notifications.instrument("webhook.circuit_breaker_tripped.rails_webhook_outbox",
90
+ subscription_id: subscription.id,
91
+ consecutive_failures: subscription.consecutive_failures)
31
92
  end
32
93
  end
33
94
  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,11 +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
+ before_save :reset_consecutive_failures_on_reactivation
11
+ after_commit :broadcast_overview_stats
10
12
 
11
13
  validates :url, presence: true, format: { with: URL_FORMAT, message: "must be a valid HTTP or HTTPS URL" }
12
14
  validates :secret, presence: true
@@ -18,10 +20,56 @@ module RailsWebhookOutbox
18
20
  events.include?(event.to_s)
19
21
  end
20
22
 
23
+ def rotate_secret!(grace_period: RailsWebhookOutbox.config.secret_rotation_grace_period, force: false)
24
+ raise SecretRotationError if previous_secret_active? && !force
25
+
26
+ update!(
27
+ previous_secret: secret,
28
+ previous_secret_expires_at: Time.current + grace_period,
29
+ secret: generate_secret_value
30
+ )
31
+ end
32
+
33
+ def previous_secret_active?
34
+ previous_secret.present? && previous_secret_expires_at.present? && previous_secret_expires_at.future?
35
+ end
36
+
37
+ def signing_secrets
38
+ previous_secret_active? ? [secret, previous_secret] : [secret]
39
+ end
40
+
41
+ def record_delivery_success!
42
+ update!(consecutive_failures: 0) if consecutive_failures != 0
43
+ end
44
+
45
+ def record_delivery_failure!(threshold: RailsWebhookOutbox.config.circuit_breaker_threshold)
46
+ with_lock do
47
+ next false unless active?
48
+
49
+ increment!(:consecutive_failures)
50
+ next false unless threshold&.positive? && consecutive_failures >= threshold
51
+
52
+ update!(active: false)
53
+ true
54
+ end
55
+ end
56
+
21
57
  private
22
58
 
23
59
  def generate_secret
24
- self.secret ||= SecureRandom.hex(32)
60
+ self.secret ||= generate_secret_value
61
+ end
62
+
63
+ def generate_secret_value
64
+ SecureRandom.hex(32)
65
+ end
66
+
67
+ def reset_consecutive_failures_on_reactivation
68
+ self.consecutive_failures = 0 if active_changed?(from: false, to: true)
69
+ end
70
+
71
+ def broadcast_overview_stats
72
+ Overview.broadcast_stats
25
73
  end
26
74
  end
27
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 %>