rails_webhook_outbox 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +65 -1
- data/Rakefile +6 -1
- data/UPGRADING.md +92 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_01_base.css +53 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_02_layout.css +88 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_03_table.css +26 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_04_badges.css +19 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_05_forms.css +42 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_06_filters.css +13 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_07_timeline.css +31 -0
- data/app/controllers/rails_webhook_outbox/base_controller.rb +15 -0
- data/app/controllers/rails_webhook_outbox/deliveries_controller.rb +46 -0
- data/app/controllers/rails_webhook_outbox/events_controller.rb +13 -0
- data/app/controllers/rails_webhook_outbox/overview_controller.rb +7 -0
- data/app/controllers/rails_webhook_outbox/subscriptions_controller.rb +62 -0
- data/app/helpers/rails_webhook_outbox/application_helper.rb +29 -0
- data/app/javascript/rails_webhook_outbox/application.js +2 -0
- data/app/javascript/rails_webhook_outbox/controllers/application.js +4 -0
- data/app/javascript/rails_webhook_outbox/controllers/index.js +4 -0
- data/app/javascript/rails_webhook_outbox/controllers/secret_controller.js +24 -0
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +15 -0
- data/app/models/rails_webhook_outbox/delivery.rb +28 -0
- data/app/models/rails_webhook_outbox/delivery_attempt.rb +13 -0
- data/app/models/rails_webhook_outbox/overview.rb +49 -0
- data/app/models/rails_webhook_outbox/subscription.rb +6 -1
- data/app/views/layouts/rails_webhook_outbox/application.html.erb +22 -0
- data/app/views/rails_webhook_outbox/deliveries/_detail.html.erb +47 -0
- data/app/views/rails_webhook_outbox/deliveries/_row.html.erb +7 -0
- data/app/views/rails_webhook_outbox/deliveries/_timeline.html.erb +41 -0
- data/app/views/rails_webhook_outbox/deliveries/index.html.erb +62 -0
- data/app/views/rails_webhook_outbox/deliveries/retry.turbo_stream.erb +2 -0
- data/app/views/rails_webhook_outbox/deliveries/show.html.erb +5 -0
- data/app/views/rails_webhook_outbox/events/index.html.erb +22 -0
- data/app/views/rails_webhook_outbox/overview/_recent_failures.html.erb +29 -0
- data/app/views/rails_webhook_outbox/overview/_stats.html.erb +19 -0
- data/app/views/rails_webhook_outbox/overview/show.html.erb +8 -0
- data/app/views/rails_webhook_outbox/subscriptions/_form.html.erb +49 -0
- data/app/views/rails_webhook_outbox/subscriptions/edit.html.erb +7 -0
- data/app/views/rails_webhook_outbox/subscriptions/index.html.erb +37 -0
- data/app/views/rails_webhook_outbox/subscriptions/new.html.erb +7 -0
- data/app/views/rails_webhook_outbox/subscriptions/show.html.erb +49 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +1 -1
- data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +4 -4
- data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +2 -2
- data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +2 -2
- data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +1 -1
- data/db/migrate/20261027000001_create_webhook_outbox_delivery_attempts.rb +16 -0
- data/lib/generators/rails_webhook_outbox/install/install_generator.rb +1 -19
- data/lib/rails_webhook_outbox/configuration.rb +3 -1
- data/lib/rails_webhook_outbox/engine.rb +16 -0
- data/lib/rails_webhook_outbox/version.rb +1 -1
- metadata +67 -3
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +0 -16
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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
|
+
· 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 %>
|
|
@@ -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,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,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,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 %>
|