webhooks-rails 0.1.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +20 -0
  5. data/app/assets/config/webhooks_manifest.js +1 -0
  6. data/app/assets/stylesheets/webhooks/application.css +15 -0
  7. data/app/controllers/webhooks/application_controller.rb +6 -0
  8. data/app/controllers/webhooks/attempts_controller.rb +25 -0
  9. data/app/controllers/webhooks/endpoints_controller.rb +65 -0
  10. data/app/controllers/webhooks/events_controller.rb +37 -0
  11. data/app/helpers/webhooks/application_helper.rb +6 -0
  12. data/app/jobs/webhooks/application_job.rb +6 -0
  13. data/app/jobs/webhooks/deliver_job.rb +11 -0
  14. data/app/mailers/webhooks/application_mailer.rb +8 -0
  15. data/app/models/webhooks/application_record.rb +7 -0
  16. data/app/models/webhooks/attempt.rb +71 -0
  17. data/app/models/webhooks/endpoint.rb +74 -0
  18. data/app/models/webhooks/event.rb +50 -0
  19. data/app/models/webhooks/event_serializer.rb +30 -0
  20. data/app/models/webhooks/request.rb +37 -0
  21. data/app/models/webhooks/response.rb +29 -0
  22. data/app/views/layouts/webhooks/application.html.erb +29 -0
  23. data/app/views/webhooks/attempts/_attempt.html.erb +32 -0
  24. data/app/views/webhooks/attempts/show.html.erb +33 -0
  25. data/app/views/webhooks/endpoints/_endpoint.html.erb +31 -0
  26. data/app/views/webhooks/endpoints/_form.html.erb +42 -0
  27. data/app/views/webhooks/endpoints/edit.html.erb +16 -0
  28. data/app/views/webhooks/endpoints/index.html.erb +41 -0
  29. data/app/views/webhooks/endpoints/new.html.erb +16 -0
  30. data/app/views/webhooks/endpoints/show.html.erb +102 -0
  31. data/app/views/webhooks/events/_event.html.erb +11 -0
  32. data/app/views/webhooks/events/index.html.erb +38 -0
  33. data/app/views/webhooks/events/new.html.erb +35 -0
  34. data/app/views/webhooks/events/show.html.erb +49 -0
  35. data/app/views/webhooks/requests/_request.html.erb +47 -0
  36. data/app/views/webhooks/responses/_response.html.erb +55 -0
  37. data/app/views/webhooks/shared/_navigation.html.erb +100 -0
  38. data/config/routes.rb +13 -0
  39. data/db/migrate/20210518022959_create_webhooks_endpoints.rb +17 -0
  40. data/db/migrate/20210518043350_create_webhooks_events.rb +12 -0
  41. data/db/migrate/20210518050123_create_webhooks_attempts.rb +36 -0
  42. data/db/migrate/20210518054916_create_webhooks_requests.rb +14 -0
  43. data/db/migrate/20210518060614_create_webhooks_responses.rb +15 -0
  44. data/lib/tasks/webhooks_tasks.rake +5 -0
  45. data/lib/webhooks-rails.rb +3 -0
  46. data/lib/webhooks.rb +21 -0
  47. data/lib/webhooks/engine.rb +31 -0
  48. data/lib/webhooks/version.rb +5 -0
  49. metadata +183 -0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhooks
4
+ class EventSerializer
5
+ include ActiveModel::Serializers::JSON
6
+
7
+ attr_reader :id
8
+ attr_reader :type
9
+ attr_reader :event
10
+ attr_reader :created_at
11
+
12
+ def initialize(request)
13
+ event = request.event
14
+
15
+ @id = event.id
16
+ @type = event.event_type
17
+ @event = ActiveSupport::JSON.decode(event.event)
18
+ @created_at = event.created_at.iso8601(6)
19
+ end
20
+
21
+ def attributes
22
+ {
23
+ 'id' => id,
24
+ 'type' => type,
25
+ 'event' => event,
26
+ 'created_at' => created_at,
27
+ }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhooks
4
+ class Request < ApplicationRecord
5
+ belongs_to :attempt, class_name: 'Webhooks::Attempt', foreign_key: :webhooks_attempt_id, inverse_of: :request
6
+ has_one :response, class_name: 'Webhooks::Response', foreign_key: :webhooks_request_id, inverse_of: :request, dependent: :destroy
7
+
8
+ # Delegated associations
9
+ has_one :endpoint, through: :attempt
10
+ has_one :event, through: :attempt
11
+
12
+ before_create :perform_request
13
+ after_create :record_response
14
+
15
+ private
16
+
17
+ def perform_request
18
+ self.body ||= EventSerializer.new(self).to_json
19
+
20
+ @response = Webhooks.client.post(endpoint.url) do |req|
21
+ timestamp = Time.zone.now
22
+ timestamped_payload = "#{timestamp.to_i}.#{body}"
23
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), endpoint.secret, timestamped_payload)
24
+
25
+ req.headers[Rails.application.config.webhooks.requests.header] = "t=#{timestamp.to_i},v1=#{signature}"
26
+
27
+ req.body = body
28
+ end
29
+
30
+ self.headers = @response.env.request_headers
31
+ end
32
+
33
+ def record_response
34
+ create_response!(response: @response)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhooks
4
+ class Response < ApplicationRecord
5
+ belongs_to :request, class_name: 'Webhooks::Request', foreign_key: :webhooks_request_id, inverse_of: :response
6
+ has_one :attempt, through: :request
7
+ has_one :endpoint, through: :attempt
8
+
9
+ validates :status_code, presence: true
10
+ validates :headers, presence: true
11
+
12
+ after_create :update_attempt_state
13
+
14
+ def update_attempt_state
15
+ case status_code
16
+ when 200..299
17
+ attempt.update!(state: :success)
18
+ else
19
+ attempt.update!(state: :error)
20
+ end
21
+ end
22
+
23
+ def response=(response)
24
+ self.status_code = response.status
25
+ self.headers = response.headers
26
+ self.body = response.body
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Webhooks</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
8
+ </head>
9
+ <body>
10
+ <!-- This example requires Tailwind CSS v2.0+ -->
11
+ <div class="h-screen flex overflow-hidden bg-gray-100">
12
+ <%= render 'webhooks/shared/navigation' %>
13
+ <div class="flex flex-col w-0 flex-1 overflow-hidden">
14
+ <div class="md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3">
15
+ <button class="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
16
+ <span class="sr-only">Open sidebar</span>
17
+ <!-- Heroicon name: outline/menu -->
18
+ <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
19
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
20
+ </svg>
21
+ </button>
22
+ </div>
23
+ <main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
24
+ <%= yield %>
25
+ </main>
26
+ </div>
27
+ </div>
28
+ </body>
29
+ </html>
@@ -0,0 +1,32 @@
1
+ <tr>
2
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
3
+ <% if attempt.success? %>
4
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
5
+ Success
6
+ </span>
7
+ <% elsif attempt.error? %>
8
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
9
+ Error <%= '(Limit)' if attempt.max_attempts? %>
10
+ </span>
11
+ <% else %>
12
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
13
+ Queued
14
+ </span>
15
+ <% end %>
16
+ </td>
17
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
18
+ <%= attempt.event.event_type %>
19
+ </td>
20
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
21
+ <%= link_to attempt.endpoint, attempt.endpoint %>
22
+ </td>
23
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
24
+ <%= link_to attempt.created_at, attempt %>
25
+ </td>
26
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">
27
+ <% if attempt.error? %>
28
+ <%= attempt.retry_at %>
29
+ <%= button_to 'Resend', reattempt_attempt_path(attempt), class: 'text-blue-600 hover:text-blue-900 bg-transparent cursor-pointer' %>
30
+ <% end %>
31
+ </td>
32
+ </tr>
@@ -0,0 +1,33 @@
1
+ <div class="py-6">
2
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 md:flex md:justify-between md:space-x-5">
3
+ <h1 class="text-2xl font-semibold text-gray-900">Attempt</h1>
4
+ </div>
5
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
6
+ <div class="py-4">
7
+ <div class="flex flex-col">
8
+ <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
9
+ <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8 space-y-6">
10
+ <div>
11
+ <%= @attempt.endpoint %>
12
+ Attempt: <%= @attempt.attempt %>
13
+ <%= @attempt.state %>
14
+ <%= @attempt.retry_at %>
15
+ </div>
16
+
17
+ <% if @attempt.request.present? %>
18
+ <%= render @attempt.request %>
19
+ <% else %>
20
+ No request.
21
+ <% end %>
22
+
23
+ <% if @attempt.response.present? %>
24
+ <%= render @attempt.response %>
25
+ <% else %>
26
+ No response.
27
+ <% end %>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
@@ -0,0 +1,31 @@
1
+ <tr>
2
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
3
+ <%= link_to endpoint, endpoint %>
4
+ </td>
5
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
6
+ <% if endpoint.enabled? %>
7
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
8
+ Enabled
9
+ </span>
10
+ <% elsif endpoint.disabled? %>
11
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
12
+ Disabled
13
+ </span>
14
+ <% else %>
15
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
16
+ Error
17
+ </span>
18
+ <% end %>
19
+ </td>
20
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
21
+ <% if endpoint.event_types.blank? %>
22
+ All events.
23
+ <% else %>
24
+ <%= pluralize endpoint.event_types.length, 'event' %>
25
+ <% end %>
26
+ </td>
27
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium sm:flex sm:items-center sm:justify-end space-x-3">
28
+ <%= link_to 'Edit', edit_endpoint_path(endpoint), class: 'text-indigo-600 hover:text-indigo-900' %>
29
+ <%= button_to 'Delete', endpoint_path(endpoint), method: 'delete', class: 'text-red-600 hover:text-red-900 bg-transparent cursor-pointer' %>
30
+ </td>
31
+ </tr>
@@ -0,0 +1,42 @@
1
+ <%= form_with model: @endpoint do |form| %>
2
+ <div class="space-y-6">
3
+ <div>
4
+ <%= form.label :url, 'URL', class: 'block text-sm font-medium text-gray-700' %>
5
+ <div class="mt-1">
6
+ <%= form.url_field :url, class: 'block w-full shadow-sm focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm border-gray-300 rounded-md py-2 px-4', placeholder: 'https://example.com/webhook' %>
7
+ </div>
8
+ </div>
9
+
10
+ <div class="space-y-4">
11
+ <div>
12
+ <h2 class="text-lg leading-6 font-medium text-gray-900">Event Types</h2>
13
+ <p class="mt-1 text-sm text-gray-500">
14
+ Select the events you'd like to subscribe to for this endpoint. If no events are subscribed to, you will receive all events.
15
+ </p>
16
+ </div>
17
+ <div class="max-w-lg space-y-4">
18
+ <%= form.collection_check_boxes :event_types, Rails.application.config.webhooks.events.types, :to_s, :to_s, include_hidden: false do |b| %>
19
+ <div>
20
+ <div class="relative flex items-start">
21
+ <div class="flex items-center h-5">
22
+ <%= b.check_box class: 'focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded' %>
23
+ </div>
24
+ <div class="ml-3 text-sm">
25
+ <%= b.label class: 'font-medium text-gray-700' %>
26
+ <p class="text-gray-500">
27
+ <%= t b.text, scope: %i[webhooks events types] %>
28
+ </p>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ <% end %>
33
+ <%= form.hidden_field :event_types, multiple: true, value: '' %>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="flex justify-end">
38
+ <%= link_to 'Cancel', endpoints_path, class: 'bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500' %>
39
+ <%= form.submit class: 'cursor-pointer ml-3 inline-flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500' %>
40
+ </div>
41
+ </div>
42
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <div class="py-6">
2
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
3
+ <h1 class="text-2xl font-semibold text-gray-900">Edit Endpoint</h1>
4
+ </div>
5
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
6
+ <div class="py-4">
7
+ <div class="flex flex-col">
8
+ <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
9
+ <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
10
+ <%= render 'form' %>
11
+ </div>
12
+ </div>
13
+ </div>
14
+ </div>
15
+ </div>
16
+ </div>
@@ -0,0 +1,41 @@
1
+ <div class="py-6">
2
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 md:flex md:justify-between md:space-x-5">
3
+ <h1 class="text-2xl font-semibold text-gray-900">Endpoints</h1>
4
+ <div class="mt-6 flex flex-col-reverse justify-stretch space-y-4 space-y-reverse sm:flex-row-reverse sm:justify-end sm:space-x-reverse sm:space-y-0 sm:space-x-3 md:mt-0 md:flex-row md:space-x-3">
5
+ <%= link_to 'New Endpoint', new_endpoint_path, class: 'inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-blue-500' %>
6
+ </div>
7
+ </div>
8
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
9
+ <div class="py-4">
10
+ <div class="flex flex-col">
11
+ <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
12
+ <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
13
+ <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
14
+ <table class="min-w-full divide-y divide-gray-200">
15
+ <thead class="bg-gray-50">
16
+ <tr>
17
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
18
+ URL
19
+ </th>
20
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
21
+ State
22
+ </th>
23
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
24
+ Event Types
25
+ </th>
26
+ <th scope="col" class="relative px-6 py-3">
27
+ <span class="sr-only">Edit</span>
28
+ </th>
29
+ </tr>
30
+ </thead>
31
+ <tbody class="bg-white divide-y divide-gray-200">
32
+ <%= render @endpoints %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </div>
@@ -0,0 +1,16 @@
1
+ <div class="py-6">
2
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
3
+ <h1 class="text-2xl font-semibold text-gray-900">New Endpoint</h1>
4
+ </div>
5
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
6
+ <div class="py-4">
7
+ <div class="flex flex-col">
8
+ <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
9
+ <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
10
+ <%= render 'form' %>
11
+ </div>
12
+ </div>
13
+ </div>
14
+ </div>
15
+ </div>
16
+ </div>
@@ -0,0 +1,102 @@
1
+ <div class="py-6">
2
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
3
+ <div class="py-4">
4
+ <div class="flex flex-col">
5
+ <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
6
+ <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8 space-y-6">
7
+ <div class="bg-white shadow overflow-hidden sm:rounded-lg">
8
+ <div class="px-4 py-5 sm:px-6 sm:flex sm:items-center sm:justify-between">
9
+ <h3 class="text-lg leading-6 font-medium text-gray-900">
10
+ <%= @endpoint %>
11
+ </h3>
12
+ <div class="mt-3 sm:mt-0 sm:ml-4">
13
+ <%= link_to 'Edit', edit_endpoint_path(@endpoint), class: 'inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500' %>
14
+ </div>
15
+ </div>
16
+ <div class="border-t border-gray-200 px-4 py-5 sm:p-0">
17
+ <dl class="sm:divide-y sm:divide-gray-200">
18
+ <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
19
+ <dt class="text-sm font-medium text-gray-500">
20
+ State
21
+ </dt>
22
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
23
+ <% if @endpoint.enabled? %>
24
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
25
+ Enabled
26
+ </span>
27
+ <% elsif @endpoint.disabled? %>
28
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
29
+ Disabled
30
+ </span>
31
+ <% else %>
32
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
33
+ Error
34
+ </span>
35
+ <% end %>
36
+ </dd>
37
+ </div>
38
+ <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
39
+ <dt class="text-sm font-medium text-gray-500">
40
+ Event types
41
+ </dt>
42
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
43
+ <% if @endpoint.event_types.blank? %>
44
+ All events.
45
+ <% else %>
46
+ <%= @endpoint.event_types.to_sentence %>
47
+ <% end %>
48
+ </dd>
49
+ </div>
50
+ <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
51
+ <dt class="text-sm font-medium text-gray-500">
52
+ Secret
53
+ </dt>
54
+ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
55
+ <%= @endpoint.secret %>
56
+ </dd>
57
+ </div>
58
+ </dl>
59
+ </div>
60
+ </div>
61
+
62
+ <div>
63
+ <h3 class="text-lg leading-6 font-medium text-gray-900">
64
+ Attempts
65
+ </h3>
66
+ <p class="mt-2 max-w-4xl text-sm text-gray-500">
67
+ All webhook delivery attempts and their status.
68
+ </p>
69
+ </div>
70
+
71
+ <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
72
+ <table class="min-w-full divide-y divide-gray-200">
73
+ <thead class="bg-gray-50">
74
+ <tr>
75
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
76
+ State
77
+ </th>
78
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
79
+ Event Type
80
+ </th>
81
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
82
+ Endpoint
83
+ </th>
84
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
85
+ Created
86
+ </th>
87
+ <th scope="col" class="relative px-6 py-3">
88
+ <span class="sr-only">Edit</span>
89
+ </th>
90
+ </tr>
91
+ </thead>
92
+ <tbody class="bg-white divide-y divide-gray-200">
93
+ <%= render @endpoint.attempts.order(created_at: :desc) %>
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>