steid

@jamesgill /

Rust28.0 KBCode·Blame·History·Raw
1//! Webhooks — `/{handle}/repos/{name}/settings/hooks`.
2//!
3//! Owner-only, and 404 for anyone else, exactly as
4//! [`repo_settings`](super::repo_settings) is: a 403 would confirm that a private
5//! repository by that name exists.
6//!
7//! Two pages. The list is where a hook is added and removed, and where its most recent
8//! delivery is visible as a dot. The detail page is the delivery log — the thing anybody
9//! actually opens, because they are here to find out why their endpoint is not being
10//! called.
11
12use std::time::SystemTime;
13
14use serde::Deserialize;
15use topcoat::{
16 Result,
17 context::Cx,
18 icon::{icon, iconify::iconify_icon},
19 router::{StatusCode, content::Form, error::not_found, page, path_param, query_params},
20 view::{attributes, component, view},
21};
22
23use crate::{
24 application::{
25 Error, RepoView, Stores, WebhookDetail, WebhookSummary, create_webhook, delete_webhook,
26 list_webhooks, redeliver, set_webhook_active, view_webhook,
27 },
28 components::{
29 badge::{BadgeVariant, badge},
30 button::{ButtonSize, ButtonVariant, button},
31 flash::{FlashKind, flash},
32 input::input,
33 label::label,
34 },
35 domain::{DomainError, WebhookDelivery, WebhookDeliveryId, WebhookId, WebhookUrl},
36 infrastructure::clock::SystemClock,
37};
38
39use super::{
40 context::{
41 current_actor, deliveries, location, memberships, orgs, owned_repo, private_targets,
42 public_origin, push_events, queries, repos, server_error, users, webhook_sender, webhooks,
43 write_failure,
44 },
45 csrf::{CsrfGuard, csrf_field, csrf_verified},
46 format::timestamp,
47 layout::narrow,
48 paging::{Pages, list_pages, requested_page, showing},
49};
50
51/// `{hook}` from the path — a webhook's id.
52#[path_param]
53struct Hook(str);
54
55/// `{delivery}` from the path — one recorded attempt's id.
56#[path_param]
57struct Delivery(str);
58
59#[derive(Debug, Deserialize)]
60struct AddForm {
61 /// The hidden field every form carries; see [`csrf`](super::csrf).
62 ///
63 /// `default` so that a submission with no token at all is refused by
64 /// `csrf_verified` — a 403 saying what happened — rather than failing
65 /// deserialization into a bare 400 that says nothing.
66 #[serde(default)]
67 csrf_token: String,
68 url: String,
69}
70
71/// What a redirect back onto one of these pages has to say for itself.
72///
73/// Three separate flags rather than one `notice=…`: each is written by a different route
74/// and read by a different sentence, and collapsing them into one string would mean
75/// parsing it back apart.
76#[query_params(error = bad_request)]
77struct Notices {
78 removed: Option<String>,
79 /// `on` or `off`, after the toggle.
80 state: Option<String>,
81 /// `ok` or `failed`, after a redelivery.
82 sent: Option<String>,
83 /// `?page=N` on the deliveries list. It sits beside the notices rather than in a
84 /// struct of its own because a redirect back onto this page carries both.
85 page: Option<String>,
86}
87
88/// Which state a toggle is asking for.
89///
90/// The *wanted* state rather than "the other one", so a reload of the POST or a second
91/// click lands on the same answer instead of flipping back — see
92/// [`set_webhook_active`].
93#[derive(Debug, Deserialize)]
94struct ToggleForm {
95 /// The hidden field every form carries; see [`csrf`](super::csrf).
96 ///
97 /// `default` so that a submission with no token at all is refused by
98 /// `csrf_verified` — a 403 saying what happened — rather than failing
99 /// deserialization into a bare 400 that says nothing.
100 #[serde(default)]
101 csrf_token: String,
102 active: String,
103 /// Present only when the form was submitted from the hook's own page, which is where
104 /// the redirect then goes back to.
105 detail: Option<String>,
106}
107
108async fn listing(cx: &Cx, repo: &RepoView) -> Result<Vec<WebhookSummary>> {
109 list_webhooks(
110 &current_actor(cx).await?,
111 &repo.handle,
112 &repo.name,
113 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
114 &webhooks(cx),
115 &deliveries(cx),
116 )
117 .await
118 .map_err(|error| match error {
119 // The guard above already answered this for the page; reaching it here means the
120 // repository went away between the two reads.
121 Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. }) => not_found().into(),
122 other => server_error(std::io::Error::other(other.to_string())),
123 })
124}
125
126#[page("/{handle}/repos/{name}/settings/hooks")]
127async fn hooks_page(cx: &Cx) -> Result {
128 let repo = owned_repo(cx).await?;
129 let listed = listing(cx, &repo).await?;
130 let notices = query_params::<Notices>(cx)?;
131
132 view! {
133 hooks_view(
134 handle: repo.handle.as_str(),
135 name: repo.name.as_str(),
136 hooks: &listed,
137 notice: Notice {
138 removed: notices.removed.is_some(),
139 state: notices.state.as_deref().unwrap_or_default(),
140 ..Notice::default()
141 },
142 )
143 }
144}
145
146/// Adds a hook and shows its secret, once.
147///
148/// **This deliberately does not redirect**, for the same reason issuing a token does not
149/// — see [`token::issue`](super::token). The secret exists only in this response, and
150/// surviving a redirect would mean putting it in a URL.
151#[page(POST "/{handle}/repos/{name}/settings/hooks")]
152async fn add(cx: &Cx, Form(submitted): Form<AddForm>) -> Result {
153 csrf_verified(cx, &submitted.csrf_token).await?;
154
155 let repo = owned_repo(cx).await?;
156
157 let outcome = create_webhook(
158 &current_actor(cx).await?,
159 &repo.handle,
160 &repo.name,
161 submitted.url.trim(),
162 SystemTime::now(),
163 private_targets(cx),
164 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
165 &webhooks(cx),
166 )
167 .await;
168
169 let (created, error) = match outcome {
170 Ok(created) => (created.secret.reveal().to_owned(), String::new()),
171 Err(error) => (String::new(), write_failure(error)?),
172 };
173
174 let listed = listing(cx, &repo).await?;
175
176 view! {
177 hooks_view(
178 handle: repo.handle.as_str(),
179 name: repo.name.as_str(),
180 hooks: &listed,
181 notice: Notice {
182 created: created.as_str(),
183 removed: false,
184 state: "",
185 error: error.as_str(),
186 // Cleared on success so the field is ready for the next one, kept on
187 // failure so a rejected URL is not silently thrown away.
188 url: if created.is_empty() { submitted.url.as_str() } else { "" },
189 },
190 )
191 }
192}
193
194/// Removes a hook, then redirects so a reload cannot repeat it.
195#[page(POST "/{handle}/repos/{name}/settings/hooks/{hook}/delete")]
196async fn remove(cx: &Cx, Form(guard): Form<CsrfGuard>) -> Result {
197 csrf_verified(cx, &guard.csrf_token).await?;
198
199 let repo = owned_repo(cx).await?;
200 let id = WebhookId::from_trusted(path_param::<Hook>(cx));
201
202 match delete_webhook(
203 &current_actor(cx).await?,
204 &repo.handle,
205 &repo.name,
206 &id,
207 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
208 &webhooks(cx),
209 )
210 .await
211 {
212 Ok(()) => {}
213 // Already gone, or never this repository's. Both are "no such hook of yours",
214 // and both leave the list looking the way the visitor wanted it.
215 Err(Error::Domain(DomainError::NotFound { .. })) => {}
216 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
217 }
218
219 view! {
220 (StatusCode::SEE_OTHER)
221 (location(&format!(
222 "/{}/repos/{}/settings/hooks?removed",
223 repo.handle, repo.name
224 ))?)
225 }
226}
227
228/// Turns a hook on or off, from either page.
229///
230/// **One route for both.** The list and the detail page render the same button against
231/// the same hook, and a second route would be the same use case call with a different
232/// redirect — so the redirect is what the form says, through the presence of `detail`,
233/// rather than what the URL says.
234#[page(POST "/{handle}/repos/{name}/settings/hooks/{hook}/toggle")]
235async fn toggle(cx: &Cx, Form(submitted): Form<ToggleForm>) -> Result {
236 csrf_verified(cx, &submitted.csrf_token).await?;
237
238 let repo = owned_repo(cx).await?;
239 let id = WebhookId::from_trusted(path_param::<Hook>(cx));
240 let wanted = submitted.active == "true";
241
242 match set_webhook_active(
243 &current_actor(cx).await?,
244 &repo.handle,
245 &repo.name,
246 &id,
247 wanted,
248 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
249 &webhooks(cx),
250 )
251 .await
252 {
253 Ok(_) => {}
254 // Gone between the page rendering and the click. The list will show that.
255 Err(Error::Domain(DomainError::NotFound { .. })) => {}
256 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
257 }
258
259 let state = if wanted { "on" } else { "off" };
260
261 let back = match submitted.detail.is_some() {
262 true => format!(
263 "/{}/repos/{}/settings/hooks/{id}?state={state}",
264 repo.handle, repo.name
265 ),
266 false => format!(
267 "/{}/repos/{}/settings/hooks?state={state}",
268 repo.handle, repo.name
269 ),
270 };
271
272 view! {
273 (StatusCode::SEE_OTHER)
274 (location(&back)?)
275 }
276}
277
278/// Sends one recorded attempt's payload again.
279///
280/// **Awaited rather than spawned**, unlike a push's deliveries: somebody is looking at
281/// this page and the answer is the whole point of the button, so the request holds for
282/// as long as one attempt takes — at most the sender's ten-second timeout, and no retry
283/// schedule behind it. Spawning would redirect to a page that does not yet know what
284/// happened.
285#[page(POST "/{handle}/repos/{name}/settings/hooks/{hook}/deliveries/{delivery}/redeliver")]
286async fn resend(cx: &Cx, Form(guard): Form<CsrfGuard>) -> Result {
287 csrf_verified(cx, &guard.csrf_token).await?;
288
289 let repo = owned_repo(cx).await?;
290 let hook = WebhookId::from_trusted(path_param::<Hook>(cx));
291 let delivery = WebhookDeliveryId::from_trusted(path_param::<Delivery>(cx));
292
293 let outcome = redeliver(
294 &current_actor(cx).await?,
295 &repo.handle,
296 &repo.name,
297 &hook,
298 &delivery,
299 &public_origin(cx),
300 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
301 &users(cx),
302 &webhooks(cx),
303 &deliveries(cx),
304 &push_events(cx),
305 &queries(cx),
306 &webhook_sender(cx),
307 &SystemClock,
308 )
309 .await;
310
311 let sent = match outcome {
312 Ok(delivery) if delivery.ok => "ok",
313 Ok(_) => "failed",
314 // An attempt or an event that is not there any more is not a page's problem: the
315 // log it redirects to is the answer either way.
316 Err(Error::Domain(DomainError::NotFound { .. })) => "failed",
317 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
318 };
319
320 view! {
321 (StatusCode::SEE_OTHER)
322 (location(&format!(
323 "/{}/repos/{}/settings/hooks/{hook}?sent={sent}",
324 repo.handle, repo.name
325 ))?)
326 }
327}
328
329#[page("/{handle}/repos/{name}/settings/hooks/{hook}")]
330async fn hook_page(cx: &Cx) -> Result {
331 let repo = owned_repo(cx).await?;
332 let id = WebhookId::from_trusted(path_param::<Hook>(cx));
333
334 let detail = view_webhook(
335 &current_actor(cx).await?,
336 &repo.handle,
337 &repo.name,
338 &id,
339 requested_page(query_params::<Notices>(cx)?.page.as_deref()),
340 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
341 &webhooks(cx),
342 &deliveries(cx),
343 )
344 .await;
345
346 let detail = match detail {
347 Ok(detail) => detail,
348 // A hook id that is not this repository's is not a page.
349 Err(_) => return Err(not_found().into()),
350 };
351
352 let notices = query_params::<Notices>(cx)?;
353
354 view! {
355 hook_view(
356 handle: repo.handle.as_str(),
357 name: repo.name.as_str(),
358 hook: &detail,
359 state: notices.state.as_deref().unwrap_or_default(),
360 sent: notices.sent.as_deref().unwrap_or_default(),
361 )
362 }
363}
364
365/// The dot beside a hook, and its label.
366///
367/// Three states, because "never delivered" is not "failing": a hook added a minute ago
368/// has nothing wrong with it. Colour is the only place on these pages it carries
369/// meaning, so the dot is the one coloured thing in a row.
370fn status_of(last: Option<&WebhookDelivery>) -> (&'static str, String) {
371 match last {
372 Some(delivery) if delivery.ok => {
373 ("bg-success", format!("{} · ok", delivery.status_label()))
374 }
375 Some(delivery) => ("bg-destructive", delivery.status_label()),
376 None => ("bg-muted-foreground/40", "no deliveries yet".to_owned()),
377 }
378}
379
380/// What the page has to say about the last thing that happened, grouped so the view
381/// takes one argument for it rather than four that must stay in the same order at three
382/// call sites — the shape `repo_settings`'s `Fields` already uses.
383#[derive(Debug, Clone, Copy, Default)]
384struct Notice<'a> {
385 /// A secret to show exactly once. Empty otherwise.
386 created: &'a str,
387 removed: bool,
388 /// `on` or `off` after the toggle, empty otherwise.
389 state: &'a str,
390 error: &'a str,
391 /// What was typed, kept on a rejected submission.
392 url: &'a str,
393}
394
395/// The list of hooks, the add form, and the secret when one has just been made.
396#[component]
397async fn hooks_view(
398 handle: &str,
399 name: &str,
400 hooks: &[WebhookSummary],
401 notice: Notice<'_>,
402) -> Result {
403 let Notice {
404 created,
405 removed,
406 state,
407 error,
408 url,
409 } = notice;
410
411 view! {
412 narrow(
413 <h1 class="text-xl font-semibold tracking-tight">"Webhooks"</h1>
414 <p class="mt-1 font-mono text-sm text-muted-foreground">
415 "@" (handle) " / " (name)
416 </p>
417 <p class="mt-2 text-sm text-muted-foreground">
418 "Every push to this repository is POSTed to each hook as signed JSON, "
419 "once per ref that moved."
420 </p>
421
422 if !created.is_empty() {
423 <div class="mt-6 space-y-2">
424 flash(
425 kind: FlashKind::Success,
426 "Copy this secret now — it is not shown again."
427 )
428 <pre class="overflow-x-auto rounded-lg border border-border bg-surface px-4 py-3 font-mono text-sm">(created)</pre>
429 <p class="text-xs text-muted-foreground">
430 "Sent as "
431 <code class="font-mono">"X-Steid-Signature-256: sha256=<hex>"</code>
432 " — the HMAC-SHA-256 of the exact body, keyed with this."
433 </p>
434 </div>
435 }
436
437 if removed {
438 <div class="mt-6">
439 flash(kind: FlashKind::Success, "Webhook removed.")
440 </div>
441 }
442
443 if !state.is_empty() {
444 <div class="mt-6">
445 flash(
446 kind: FlashKind::Success,
447 (if state == "on" {
448 "Webhook turned on. The next push will be delivered."
449 } else {
450 "Webhook turned off. Its deliveries are kept."
451 })
452 )
453 </div>
454 }
455
456 if !error.is_empty() {
457 <div class="mt-6">
458 flash(kind: FlashKind::Error, (error))
459 </div>
460 }
461
462 <form
463 method="post"
464 action=(format!("/{handle}/repos/{name}/settings/hooks"))
465 class="mt-6 space-y-5"
466 >
467 csrf_field()
468 <div class="space-y-2">
469 label(attrs: attributes! { for="url" }, "Payload URL")
470 input(attrs: attributes! {
471 id="url"
472 name="url"
473 type="text"
474 value=(url)
475 placeholder="https://example.com/steid-hook"
476 required=(true)
477 autocomplete="off"
478 maxlength=(WebhookUrl::MAX_LEN.to_string())
479 })
480 <p class="text-xs text-muted-foreground">
481 "Must be reachable from the internet. An address on this machine "
482 "or its private network is refused."
483 </p>
484 </div>
485
486 button(attrs: attributes! { type="submit" }, "Add webhook")
487 </form>
488
489 <section class="mt-10">
490 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
491 "Hooks"
492 </h2>
493
494 if hooks.is_empty() {
495 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
496 "No webhooks yet."
497 </p>
498 } else {
499 <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
500 for hook in hooks {
501 <li class="flex items-center gap-3 px-4 py-3">
502 <span
503 class=(format!("size-2 shrink-0 rounded-full {}", status_of(hook.last.as_ref()).0))
504 title=(status_of(hook.last.as_ref()).1)
505 ></span>
506 <a
507 href=(format!("/{handle}/repos/{name}/settings/hooks/{}", hook.id))
508 class="min-w-0 flex-1 truncate font-mono text-sm hover:underline"
509 >(hook.url.as_str())</a>
510 if !hook.active {
511 badge(variant: BadgeVariant::Outline, "Off")
512 }
513 <span class="hidden shrink-0 font-mono text-xs text-muted-foreground sm:inline">
514 (status_of(hook.last.as_ref()).1)
515 </span>
516 // The wanted state, not "the other one": a reload of the
517 // POST then lands on the same answer rather than
518 // flipping the hook back.
519 <form
520 method="post"
521 action=(format!(
522 "/{handle}/repos/{name}/settings/hooks/{}/toggle",
523 hook.id
524 ))
525 class="shrink-0"
526 >
527 csrf_field()
528 <input
529 type="hidden"
530 name="active"
531 value=(if hook.active { "false" } else { "true" })
532 />
533 button(
534 attrs: attributes! { type="submit" },
535 variant: ButtonVariant::Ghost,
536 size: ButtonSize::Sm,
537 (if hook.active { "Turn off" } else { "Turn on" })
538 )
539 </form>
540 <form
541 method="post"
542 action=(format!(
543 "/{handle}/repos/{name}/settings/hooks/{}/delete",
544 hook.id
545 ))
546 class="shrink-0"
547 >
548 csrf_field()
549 // Ghost and small: the dot is the only coloured
550 // thing in a row, and a filled destructive button
551 // per hook would make the list read as a warning.
552 button(
553 attrs: attributes! { type="submit" },
554 variant: ButtonVariant::Ghost,
555 size: ButtonSize::Sm,
556 "Delete"
557 )
558 </form>
559 </li>
560 }
561 </ul>
562 }
563 </section>
564
565 <p class="mt-8">
566 <a
567 href=(format!("/{handle}/repos/{name}/settings"))
568 class="text-sm text-muted-foreground hover:text-foreground"
569 >"Back to repository settings"</a>
570 </p>
571 )
572 }
573}
574
575/// One hook and its recent attempts.
576#[component]
577async fn hook_view(
578 handle: &str,
579 name: &str,
580 hook: &WebhookDetail,
581 /// `on` or `off` after the toggle, empty otherwise.
582 state: &str,
583 /// `ok` or `failed` after a redelivery, empty otherwise.
584 sent: &str,
585) -> Result {
586 let deliveries = &hook.deliveries;
587 let sentence = showing(
588 deliveries.offset(),
589 deliveries.items.len(),
590 deliveries.total,
591 "attempts",
592 );
593 let pages = Pages::numbered(deliveries.page, deliveries.pages, |page| {
594 format!(
595 "/{handle}/repos/{name}/settings/hooks/{}?page={page}",
596 hook.id
597 )
598 });
599
600 view! {
601 narrow(
602 <div class="flex items-start justify-between gap-4">
603 <div class="min-w-0">
604 <h1 class="text-xl font-semibold tracking-tight">"Webhook"</h1>
605 <p class="mt-1 break-all font-mono text-sm text-muted-foreground">
606 (hook.url.as_str())
607 </p>
608 <p class="mt-1 text-xs text-muted-foreground">
609 "Added " (timestamp(hook.created_at))
610 if !hook.active { " · not active" }
611 </p>
612 </div>
613
614 // `detail` is what sends the redirect back here rather than to the list.
615 <form
616 method="post"
617 action=(format!("/{handle}/repos/{name}/settings/hooks/{}/toggle", hook.id))
618 class="shrink-0"
619 >
620 csrf_field()
621 <input
622 type="hidden"
623 name="active"
624 value=(if hook.active { "false" } else { "true" })
625 />
626 <input type="hidden" name="detail" value="1" />
627 button(
628 attrs: attributes! { type="submit" },
629 variant: ButtonVariant::Outline,
630 size: ButtonSize::Sm,
631 (if hook.active { "Turn off" } else { "Turn on" })
632 )
633 </form>
634 </div>
635
636 if !state.is_empty() {
637 <div class="mt-4">
638 flash(
639 kind: FlashKind::Success,
640 (if state == "on" {
641 "Webhook turned on. The next push will be delivered."
642 } else {
643 "Webhook turned off. Its deliveries are kept."
644 })
645 )
646 </div>
647 }
648
649 if !sent.is_empty() {
650 <div class="mt-4">
651 if sent == "ok" {
652 flash(kind: FlashKind::Success, "Redelivered — the endpoint accepted it.")
653 } else {
654 flash(
655 kind: FlashKind::Error,
656 "Redelivery failed. The newest attempt below says what came back."
657 )
658 }
659 </div>
660 }
661
662 <section class="mt-6">
663 <div class="flex items-baseline justify-between gap-4">
664 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
665 "Deliveries"
666 </h2>
667 </div>
668
669 if hook.deliveries.items.is_empty() {
670 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
671 "Nothing has been delivered yet. Push to this repository to try it."
672 </p>
673 } else {
674 <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
675 for delivery in &hook.deliveries.items {
676 delivery_row(
677 handle: handle,
678 name: name,
679 hook: &hook.id,
680 delivery: delivery,
681 )
682 }
683 </ul>
684 list_pages(summary: sentence.as_str(), pages: &pages)
685 }
686 </section>
687
688 <p class="mt-8">
689 <a
690 href=(format!("/{handle}/repos/{name}/settings/hooks"))
691 class="text-sm text-muted-foreground hover:text-foreground"
692 >"Back to webhooks"</a>
693 </p>
694 )
695 }
696}
697
698/// One attempt: when, which try, what came back, and how long it took.
699///
700/// The response is a `<details>` rather than a column — most of them are empty, and the
701/// one that is not is the whole reason somebody opened this page.
702#[component]
703async fn delivery_row(
704 handle: &str,
705 name: &str,
706 hook: &WebhookId,
707 delivery: &WebhookDelivery,
708) -> Result {
709 let (dot, _) = status_of(Some(delivery));
710
711 view! {
712 <li class="px-4 py-2.5">
713 <div class="flex items-center gap-3 text-xs">
714 <span class=(format!("size-2 shrink-0 rounded-full {dot}"))></span>
715 <span class="font-mono text-muted-foreground">
716 (timestamp(delivery.delivered_at))
717 </span>
718 <span class="font-mono">(delivery.status_label())</span>
719 <span class="ml-auto shrink-0 font-mono text-muted-foreground">
720 "try " (delivery.attempt.to_string())
721 " · " (delivery.duration_ms.to_string()) "ms"
722 </span>
723 // Ghost and small, like Delete on the list: the dot is the only coloured
724 // thing in a row, and every row carrying a filled button would make the
725 // log read as a list of problems.
726 <form
727 method="post"
728 action=(format!(
729 "/{handle}/repos/{name}/settings/hooks/{hook}/deliveries/{}/redeliver",
730 delivery.id
731 ))
732 class="shrink-0"
733 >
734 csrf_field()
735 button(
736 attrs: attributes! { type="submit" },
737 variant: ButtonVariant::Ghost,
738 size: ButtonSize::Sm,
739 "Redeliver"
740 )
741 </form>
742 </div>
743
744 if !delivery.response.is_empty() {
745 <details class="mt-1.5">
746 <summary class="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
747 icon(data: iconify_icon!("feather:chevron-right"), attrs: attributes! {
748 class="size-3.5"
749 })
750 "Response"
751 </summary>
752 <pre class="mt-1.5 max-h-48 overflow-auto rounded-md border border-border bg-surface px-3 py-2 font-mono text-xs whitespace-pre-wrap">(delivery.response.as_str())</pre>
753 </details>
754 }
755 </li>
756 }
757}