Skip to content

Commit be3a5a0

Browse files
feat: add notification channel test button and delivery detail view
Add ability to send test notifications through channels and inspect full delivery attempt details including request/response bodies.
1 parent 15ec4ae commit be3a5a0

10 files changed

Lines changed: 490 additions & 20 deletions

File tree

lib/sentinel_cp/events.ex

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,54 @@ defmodule SentinelCp.Events do
189189

190190
def get_delivery_attempt(id), do: Repo.get(DeliveryAttempt, id)
191191

192+
def get_delivery_attempt!(id), do: Repo.get!(DeliveryAttempt, id)
193+
194+
@doc """
195+
Sends a test notification through a channel by creating a synthetic
196+
`system.test` event and scheduling delivery.
197+
"""
198+
def test_channel(%Channel{} = channel) do
199+
now = DateTime.utc_now() |> DateTime.truncate(:second)
200+
201+
{:ok, event} =
202+
create_event(%{
203+
type: "system.test",
204+
payload: %{
205+
channel_id: channel.id,
206+
channel_name: channel.name,
207+
message: "Test notification from Sentinel CP"
208+
},
209+
project_id: channel.project_id,
210+
emitted_at: now
211+
})
212+
213+
{:ok, attempt} =
214+
%DeliveryAttempt{}
215+
|> DeliveryAttempt.changeset(%{
216+
event_id: event.id,
217+
channel_id: channel.id,
218+
status: "pending",
219+
attempt_number: 1
220+
})
221+
|> Repo.insert()
222+
223+
DeliveryWorker.enqueue(attempt.id)
224+
{:ok, attempt}
225+
end
226+
227+
@doc """
228+
Lists all delivery attempts for a given event+channel pair, ordered by attempt number.
229+
Used to show the retry timeline for a delivery.
230+
"""
231+
def list_attempt_chain(event_id, channel_id) do
232+
from(d in DeliveryAttempt,
233+
where: d.event_id == ^event_id and d.channel_id == ^channel_id,
234+
order_by: [asc: d.attempt_number],
235+
preload: [:event, :channel]
236+
)
237+
|> Repo.all()
238+
end
239+
192240
def list_delivery_attempts(opts \\ []) do
193241
limit = Keyword.get(opts, :limit, 50)
194242
channel_id = Keyword.get(opts, :channel_id)

lib/sentinel_cp/events/delivery_attempt.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ defmodule SentinelCp.Events.DeliveryAttempt do
1919
field :attempt_number, :integer, default: 1
2020
field :next_retry_at, :utc_datetime
2121
field :completed_at, :utc_datetime
22+
field :request_body, :string
23+
field :response_body, :string
2224

2325
belongs_to :event, SentinelCp.Events.Event
2426
belongs_to :channel, SentinelCp.Events.Channel
@@ -37,7 +39,9 @@ defmodule SentinelCp.Events.DeliveryAttempt do
3739
:error,
3840
:attempt_number,
3941
:next_retry_at,
40-
:completed_at
42+
:completed_at,
43+
:request_body,
44+
:response_body
4145
])
4246
|> validate_required([:event_id, :channel_id, :status, :attempt_number])
4347
|> validate_inclusion(:status, @statuses)
@@ -49,7 +53,7 @@ defmodule SentinelCp.Events.DeliveryAttempt do
4953
now = DateTime.utc_now() |> DateTime.truncate(:second)
5054

5155
attempt
52-
|> cast(attrs, [:status, :http_status, :latency_ms, :error])
56+
|> cast(attrs, [:status, :http_status, :latency_ms, :error, :request_body, :response_body])
5357
|> put_change(:completed_at, now)
5458
|> validate_inclusion(:status, @statuses)
5559
end

lib/sentinel_cp/events/delivery_worker.ex

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ defmodule SentinelCp.Events.DeliveryWorker do
4141
end
4242
end
4343

44+
@max_response_body_size 10_240
45+
4446
defp execute_delivery(attempt, event, channel) do
4547
start_time = System.monotonic_time(:millisecond)
4648

@@ -49,62 +51,103 @@ defmodule SentinelCp.Events.DeliveryWorker do
4951
|> Ecto.Changeset.change(%{status: "delivering"})
5052
|> Repo.update!()
5153

52-
result = deliver_to_channel(event, channel)
54+
{result, request_body, response_body} = deliver_to_channel(event, channel)
5355
latency_ms = System.monotonic_time(:millisecond) - start_time
56+
response_body = truncate_body(response_body)
5457

5558
case result do
5659
{:ok, http_status} ->
5760
attempt
5861
|> DeliveryAttempt.complete_changeset(%{
5962
status: "delivered",
6063
http_status: http_status,
61-
latency_ms: latency_ms
64+
latency_ms: latency_ms,
65+
request_body: request_body,
66+
response_body: response_body
6267
})
6368
|> Repo.update!()
6469

6570
:ok
6671

6772
{:error, reason} ->
68-
handle_failure(attempt, reason, latency_ms)
73+
handle_failure(attempt, reason, latency_ms, request_body, response_body)
6974
end
7075
end
7176

77+
defp truncate_body(nil), do: nil
78+
79+
defp truncate_body(body) when byte_size(body) > @max_response_body_size do
80+
String.slice(body, 0, @max_response_body_size) <> "\n... [truncated]"
81+
end
82+
83+
defp truncate_body(body), do: body
84+
7285
defp deliver_to_channel(event, %Channel{type: "slack", config: config}) do
7386
payload = Adapters.Slack.format_payload(event)
87+
request_body = Jason.encode!(payload, pretty: true)
7488
webhook_url = config["webhook_url"]
75-
Adapters.Slack.deliver(webhook_url, payload)
89+
result = Adapters.Slack.deliver(webhook_url, payload)
90+
{result, request_body, extract_response_body(result)}
7691
end
7792

7893
defp deliver_to_channel(event, %Channel{type: "pagerduty", config: config}) do
7994
routing_key = config["routing_key"]
8095
payload = Adapters.PagerDuty.format_payload(event, routing_key)
81-
Adapters.PagerDuty.deliver(routing_key, payload)
96+
request_body = Jason.encode!(payload, pretty: true)
97+
result = Adapters.PagerDuty.deliver(routing_key, payload)
98+
{result, request_body, extract_response_body(result)}
8299
end
83100

84101
defp deliver_to_channel(event, %Channel{type: "email", config: config}) do
85102
email_payload = Adapters.Email.format_payload(event)
103+
104+
request_body =
105+
Jason.encode!(%{to: config["to"], subject: email_payload.subject}, pretty: true)
106+
86107
to = config["to"]
87108
from = config["from"] || "noreply@sentinel-cp.local"
88-
Adapters.Email.deliver(to, from, email_payload.subject, email_payload.body)
109+
result = Adapters.Email.deliver(to, from, email_payload.subject, email_payload.body)
110+
111+
response_body =
112+
case result do
113+
{:ok, _} -> "Email queued"
114+
{:error, reason} -> inspect(reason)
115+
end
116+
117+
{result, request_body, response_body}
89118
end
90119

91120
defp deliver_to_channel(event, %Channel{type: "teams", config: config}) do
92121
payload = Adapters.Teams.format_payload(event)
122+
request_body = Jason.encode!(payload, pretty: true)
93123
webhook_url = config["webhook_url"]
94-
Adapters.Teams.deliver(webhook_url, payload)
124+
result = Adapters.Teams.deliver(webhook_url, payload)
125+
{result, request_body, extract_response_body(result)}
95126
end
96127

97128
defp deliver_to_channel(event, %Channel{type: "webhook", config: config} = channel) do
98129
payload = Adapters.Webhook.format_payload(event)
130+
request_body = Jason.encode!(payload, pretty: true)
99131
url = config["url"]
100-
Adapters.Webhook.deliver(url, payload, channel.signing_secret)
132+
result = Adapters.Webhook.deliver(url, payload, channel.signing_secret)
133+
{result, request_body, extract_response_body(result)}
101134
end
102135

103136
defp deliver_to_channel(_event, %Channel{type: type}) do
104-
{:error, {:unknown_channel_type, type}}
137+
{{:error, {:unknown_channel_type, type}}, nil, nil}
105138
end
106139

107-
defp handle_failure(attempt, reason, latency_ms) do
140+
defp extract_response_body({:ok, _status}), do: nil
141+
142+
defp extract_response_body({:error, {:http_error, _status, body}}) when is_binary(body),
143+
do: body
144+
145+
defp extract_response_body({:error, {:http_error, _status, body}}),
146+
do: inspect(body)
147+
148+
defp extract_response_body({:error, _reason}), do: nil
149+
150+
defp handle_failure(attempt, reason, latency_ms, request_body, response_body) do
108151
error_msg = inspect(reason)
109152

110153
http_status =
@@ -120,7 +163,9 @@ defmodule SentinelCp.Events.DeliveryWorker do
120163
status: "dead_letter",
121164
http_status: http_status,
122165
latency_ms: latency_ms,
123-
error: error_msg
166+
error: error_msg,
167+
request_body: request_body,
168+
response_body: response_body
124169
})
125170
|> Repo.update!()
126171

@@ -132,13 +177,15 @@ defmodule SentinelCp.Events.DeliveryWorker do
132177
# Schedule retry with exponential backoff
133178
next_retry = DeliveryAttempt.next_retry_time(attempt.attempt_number)
134179

135-
{:ok, new_attempt} =
180+
{:ok, _new_attempt} =
136181
attempt
137182
|> DeliveryAttempt.complete_changeset(%{
138183
status: "failed",
139184
http_status: http_status,
140185
latency_ms: latency_ms,
141-
error: error_msg
186+
error: error_msg,
187+
request_body: request_body,
188+
response_body: response_body
142189
})
143190
|> Repo.update()
144191

@@ -155,8 +202,6 @@ defmodule SentinelCp.Events.DeliveryWorker do
155202
|> Repo.insert()
156203

157204
# Schedule the retry with delay
158-
delay_seconds = DateTime.diff(next_retry, DateTime.utc_now(), :second) |> max(1)
159-
160205
%{attempt_id: retry_attempt.id}
161206
|> __MODULE__.new(scheduled_at: next_retry)
162207
|> Oban.insert()

lib/sentinel_cp_web/live/notifications_live/channel_show.ex

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ defmodule SentinelCpWeb.NotificationsLive.ChannelShow do
2626
end
2727
end
2828

29+
@impl true
30+
def handle_event("send_test", _, socket) do
31+
channel = socket.assigns.channel
32+
project = socket.assigns.project
33+
34+
case Events.test_channel(channel) do
35+
{:ok, _attempt} ->
36+
Audit.log_user_action(
37+
socket.assigns.current_user,
38+
"test",
39+
"notification_channel",
40+
channel.id,
41+
project_id: project.id
42+
)
43+
44+
attempts = Events.list_delivery_attempts(channel_id: channel.id, limit: 20)
45+
46+
{:noreply,
47+
socket
48+
|> assign(:attempts, attempts)
49+
|> put_flash(:info, "Test notification sent.")}
50+
51+
{:error, _} ->
52+
{:noreply, put_flash(socket, :error, "Could not send test notification.")}
53+
end
54+
end
55+
2956
@impl true
3057
def handle_event("delete", _, socket) do
3158
channel = socket.assigns.channel
@@ -68,6 +95,9 @@ defmodule SentinelCpWeb.NotificationsLive.ChannelShow do
6895
<span class="badge badge-sm badge-outline">{@channel.type}</span>
6996
</:badge>
7097
<:action>
98+
<button phx-click="send_test" class="btn btn-outline btn-sm">
99+
Send Test
100+
</button>
71101
<.link navigate={edit_path(@org, @project, @channel)} class="btn btn-outline btn-sm">
72102
Edit
73103
</.link>
@@ -129,7 +159,11 @@ defmodule SentinelCpWeb.NotificationsLive.ChannelShow do
129159
</thead>
130160
<tbody>
131161
<tr :for={a <- @attempts}>
132-
<td><.status_badge status={a.status} /></td>
162+
<td>
163+
<.link navigate={attempt_path(@org, @project, a)} class="link">
164+
<.status_badge status={a.status} />
165+
</.link>
166+
</td>
133167
<td>{a.attempt_number}</td>
134168
<td>{a.http_status || "—"}</td>
135169
<td>{if a.latency_ms, do: "#{a.latency_ms}ms", else: "—"}</td>
@@ -182,4 +216,10 @@ defmodule SentinelCpWeb.NotificationsLive.ChannelShow do
182216

183217
defp edit_path(nil, project, channel),
184218
do: ~p"/projects/#{project.slug}/notifications/channels/#{channel.id}/edit"
219+
220+
defp attempt_path(%{slug: org_slug}, project, attempt),
221+
do: ~p"/orgs/#{org_slug}/projects/#{project.slug}/notifications/delivery/#{attempt.id}"
222+
223+
defp attempt_path(nil, project, attempt),
224+
do: ~p"/projects/#{project.slug}/notifications/delivery/#{attempt.id}"
185225
end

lib/sentinel_cp_web/live/notifications_live/delivery.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,12 @@ defmodule SentinelCpWeb.NotificationsLive.Delivery do
130130
<tbody>
131131
<tr :for={a <- @attempts}>
132132
<td>
133-
<span class="font-mono text-sm">{(a.event && a.event.type) || "—"}</span>
133+
<.link
134+
navigate={attempt_path(@org, @project, a)}
135+
class="font-mono text-sm link"
136+
>
137+
{(a.event && a.event.type) || "—"}
138+
</.link>
134139
</td>
135140
<td>
136141
<span class="text-sm">{(a.channel && a.channel.name) || "—"}</span>
@@ -253,4 +258,10 @@ defmodule SentinelCpWeb.NotificationsLive.Delivery do
253258

254259
defp delivery_path(nil, project),
255260
do: ~p"/projects/#{project.slug}/notifications/delivery"
261+
262+
defp attempt_path(%{slug: org_slug}, project, attempt),
263+
do: ~p"/orgs/#{org_slug}/projects/#{project.slug}/notifications/delivery/#{attempt.id}"
264+
265+
defp attempt_path(nil, project, attempt),
266+
do: ~p"/projects/#{project.slug}/notifications/delivery/#{attempt.id}"
256267
end

0 commit comments

Comments
 (0)