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.
- checksums.yaml +4 -4
- data/README.md +218 -4
- 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 +62 -1
- 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 +50 -2
- 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 +6 -0
- data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
- 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 +14 -1
- data/lib/rails_webhook_outbox/engine.rb +16 -0
- data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
- data/lib/rails_webhook_outbox/sender.rb +2 -1
- data/lib/rails_webhook_outbox/signature.rb +2 -2
- data/lib/rails_webhook_outbox/version.rb +1 -1
- data/lib/rails_webhook_outbox.rb +1 -0
- data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
- metadata +70 -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 -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,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,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
|
-
|
|
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
|
|
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 ||=
|
|
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
|
+
· HTTP <%= attempt.response_code || "—" %>
|
|
20
|
+
<% if attempt.duration_ms %>· <%= 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 %>
|