Compare commits

2 Commits
Author SHA1 Message Date
sneak e82b1cc3df Lint static/js/ with ESLint in Docker (closes #120)
check / check (push) Successful in 3m29s
ESLint, pinned by package.json and yarn.lock, runs in a new js-lint
stage of the Dockerfile on the pinned node 24 LTS image. It starts from
a js-deps stage that installs ESLint and stays cached until those two
files change. script/lint builds js-lint after the Go lint, and the
build stage depends on it, so make check and the image build both fail
on a violation. eslint.config.mjs turns on the styleguide's checkable
rules: no-var and prefer-const.

Model: opus-5-5
2026-10-03 02:12:38 +00:00
clawbot d8c60c9b67 Event log: show attempt and delivery times, label replays, zone event times (closes #386)
check / check (push) Successful in 3m21s
In the event log and on an event's page, attempts and deliveries showed no time, event times had no zone, and a replay looked like the original it repeated. Each attempt now shows when its result was recorded and each delivery when it was created, as how long ago with the full UTC time on hover, and the event log's event times read the same way. A delivery created by Replay records it in a new `replay` column and is labelled a replay in the event's summary line and its delivery list; replays made before this change are not labelled. A delivery's row is now drawn by one template, `delivery_row`, that both pages share.

Model: opus-5-5
2026-10-03 04:12:27 +02:00
15 changed files with 186 additions and 32 deletions
+2 -1
View File
@@ -15,7 +15,8 @@ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context. # needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
# The js-lint stage installs ESLint itself; a host copy would overwrite it. # The js-deps stage installs ESLint; a host copy would overwrite it at the
# js-lint stage's `COPY . .`.
node_modules/ node_modules/
.env .env
.env.* .env.*
+10 -4
View File
@@ -1834,6 +1834,7 @@ status across potentially multiple attempts.
| `target_id`| UUID | Foreign key → Target | | `target_id`| UUID | Foreign key → Target |
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | | `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) | | `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
| `replay` | boolean | Whether the delivery was created by **Replay** |
**Relations:** Belongs to Event. Belongs to Target. Has many **Relations:** Belongs to Event. Belongs to Target. Has many
DeliveryResults. DeliveryResults.
@@ -1853,7 +1854,8 @@ NEW `pending` delivery for the same event and target and hands it to
the engine on the ordinary path — same retries, same SSRF guard, same the engine on the ordinary path — same retries, same SSRF guard, same
circuit breaker as a first attempt. It never touches the delivery it circuit breaker as a first attempt. It never touches the delivery it
repeats: that row's status, timestamps and recorded attempts stand as repeats: that row's status, timestamps and recorded attempts stand as
the record of what happened. the record of what happened. The new delivery records `replay`, and the
event log and the event's page label it a replay.
What is re-sent is the stored event body, against the target's What is re-sent is the stored event body, against the target's
configuration **as it stands now** — the point of a replay is to configuration **as it stands now** — the point of a replay is to
@@ -1907,6 +1909,10 @@ A `database` or `log` target sends no HTTP request, so in the event log and
on the event's page its attempts show no status: a successful one reads on the event's page its attempts show no status: a successful one reads
"archived" or "written to the log". "archived" or "written to the log".
The event log and the event's page show when each attempt was recorded and
when each delivery was created, as the recent events list shows when an event
arrived: how long ago, with the full UTC time on hover.
**Relations:** Belongs to Delivery. **Relations:** Belongs to Delivery.
#### EventTotals, TargetTotals and EntrypointTotals #### EventTotals, TargetTotals and EntrypointTotals
@@ -3552,9 +3558,9 @@ linter's version is fixed independently of the compiler's:
`css-check` fails when the committed file differs from the generated `css-check` fails when the committed file differs from the generated
one, and `make css` writes the generated file out from `css-output` one, and `make css` writes the generated file out from `css-output`
(see [Stylesheet](#stylesheet)). (see [Stylesheet](#stylesheet)).
3. **JavaScript lint stage** (`node:24.21.0-alpine`, with yarn) — 3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) —
installs ESLint from `yarn.lock` and runs it over `static/js/` (see `js-deps` installs ESLint from `yarn.lock` and `js-lint` runs it over
[Linting](#linting)). `static/js/` (see [Linting](#linting)).
4. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint, 4. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint,
`css-check` and `js-lint` stages passing (it copies a file from `css-check` and `js-lint` stages passing (it copies a file from
each), runs each), runs
+4
View File
@@ -56,6 +56,10 @@ type Delivery struct {
// the index. // the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Replay is set on a delivery created by the event log's Replay
// action, so the pages can tell it from the delivery it repeats.
Replay bool `gorm:"not null;default:false" json:"replay"`
// Relations. No model marshals the record it belongs to: // Relations. No model marshals the record it belongs to:
// Event.Deliveries and Target.Deliveries lead back here, and the // Event.Deliveries and Target.Deliveries lead back here, and the
// JSON could loop. // JSON could loop.
+1
View File
@@ -282,6 +282,7 @@ func createReplayDelivery(
EventID: event.ID, EventID: event.ID,
TargetID: target.ID, TargetID: target.ID,
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
Replay: true,
} }
err := webhookDB.Transaction(func(tx *gorm.DB) error { err := webhookDB.Transaction(func(tx *gorm.DB) error {
+56
View File
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -528,3 +529,58 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.NotContains(t, unknown, "alert-success") assert.NotContains(t, unknown, "alert-success")
assert.NotContains(t, unknown, "made-up") assert.NotContains(t, unknown, "made-up")
} }
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
// by Replay is labelled as a replay in the event's summary line in the
// event log, and in the list of the event's deliveries there and on
// the event's page, while the delivery it repeats is not.
func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code)
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, eventLog, tgt.Name+": failed")
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
w = serveEventPage(t, h, sess, wh.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
// In each delivery list a row names the target, then the label if
// it is a replay, then its status: the replay is still pending, the
// original failed.
replayRow := tgt.Name + `</span> ` +
`<span class="text-xs text-gray-500">replay</span> ` +
`<span class="text-xs text-gray-400">pending</span>`
originalRow := tgt.Name + `</span> ` +
`<span class="text-xs text-red-600">failed</span>`
for _, page := range []string{eventLog, w.Body.String()} {
page = strings.Join(strings.Fields(page), " ")
assert.Contains(t, page, replayRow)
assert.Contains(t, page, originalRow)
}
}
@@ -3,6 +3,7 @@ package handlers
import ( import (
"time" "time"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -47,6 +48,11 @@ type DeliveryResultView struct {
AttemptNum int AttemptNum int
Success bool Success bool
// Ran is how long ago the attempt was recorded, and RanUTC the
// full timestamp the page shows on hover.
Ran string
RanUTC string
// StatusCode is 0 when the attempt never got a response, // StatusCode is 0 when the attempt never got a response,
// which is why the page asks HasStatusCode rather than // which is why the page asks HasStatusCode rather than
// printing the number. // printing the number.
@@ -160,6 +166,8 @@ func (r *deliveryResultRow) view(
return DeliveryResultView{ return DeliveryResultView{
AttemptNum: r.AttemptNum, AttemptNum: r.AttemptNum,
Success: r.Success, Success: r.Success,
Ran: humanize.Time(r.CreatedAt),
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
StatusCode: r.StatusCode, StatusCode: r.StatusCode,
Error: redactor.Redact(r.Error), Error: redactor.Redact(r.Error),
DurationMS: r.Duration, DurationMS: r.Duration,
+52
View File
@@ -0,0 +1,52 @@
package handlers_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestEventLog_TimesCarryTheirZone proves that the event log shows
// when an event arrived, and that it and the event's page show when
// each delivery was created and each attempt recorded: each as how
// long ago, with the full UTC time on hover.
func TestEventLog_TimesCarryTheirZone(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
now := time.Now().UTC().Truncate(time.Second)
receivedAt := now.Add(-3 * time.Hour)
createdAt := now.Add(-90 * time.Minute)
ranAt := now.Add(-30 * time.Minute)
event := f.event(t, contentTypeJSON, "{}", receivedAt)
dlv := f.deliveryQueuedAt(
t, event, target.ID, database.DeliveryStatusDelivered, createdAt,
)
f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt))
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
eventPage := w.Body.String()
eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
received := receivedAt.Format(time.DateTime)
assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago</span>`)
assert.NotContains(t, eventLog, received+"</span>",
"an event's time must not be written without its zone")
assert.Contains(t, eventPage, received+" UTC")
for _, page := range []string{eventLog, eventPage} {
assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+
` UTC">created 1 hour ago</span>`)
assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+
` UTC">30 minutes ago</span>`)
}
}
+9 -2
View File
@@ -3,6 +3,8 @@ package handlers
import ( import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/dustin/go-humanize"
) )
// eventLogColumns is the event log's projection. The casts to // eventLogColumns is the event log's projection. The casts to
@@ -28,10 +30,14 @@ const eventColumns = "id, created_at, method, content_type, " +
// DeliveryView and TargetView. // DeliveryView and TargetView.
type EventLogView struct { type EventLogView struct {
ID string ID string
CreatedAt time.Time
Method string Method string
ContentType string ContentType string
// Received is how long ago the event arrived, and ReceivedUTC
// the full timestamp.
Received string
ReceivedUTC string
Body BodyView Body BodyView
// ResubmittedFromID names the event this one was copied // ResubmittedFromID names the event this one was copied
@@ -77,9 +83,10 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
return EventLogView{ return EventLogView{
ID: r.ID, ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method, Method: r.Method,
ContentType: r.ContentType, ContentType: r.ContentType,
Received: humanize.Time(r.CreatedAt),
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Body: newBodyView( Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
), ),
+4 -2
View File
@@ -165,10 +165,12 @@ func New(
), ),
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate( "source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_attempts.html", "source_logs.html", "event_body.html", "delivery_row.html",
"delivery_attempts.html",
), ),
"event_detail.html": parsePageTemplate( "event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_attempts.html", "event_detail.html", "event_body.html", "delivery_row.html",
"delivery_attempts.html",
), ),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"), "error.html": parsePageTemplate("error.html"),
+13
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
@@ -95,6 +96,14 @@ type DeliveryView struct {
Status database.DeliveryStatus Status database.DeliveryStatus
Target delivery.TargetView Target delivery.TargetView
// Replay is set on a delivery the Replay action created.
Replay bool
// Created is how long ago the delivery was created, and
// CreatedUTC the full timestamp the page shows on hover.
Created string
CreatedUTC string
// Results is this delivery's attempts in attempt order, // Results is this delivery's attempts in attempt order,
// bounded by maxRenderedAttempts. Without them a failure // bounded by maxRenderedAttempts. Without them a failure
// renders as the status word alone and says nothing about // renders as the status word alone and says nothing about
@@ -1410,6 +1419,7 @@ func (h *Handlers) newDeliveryViews(
for i := range deliveries { for i := range deliveries {
target := targetMap[deliveries[i].TargetID] target := targetMap[deliveries[i].TargetID]
rows := attempts[deliveries[i].ID] rows := attempts[deliveries[i].ID]
created := deliveries[i].CreatedAt
results, omitted := renderedAttempts( results, omitted := renderedAttempts(
rows, target.Redactor, rows, target.Redactor,
@@ -1419,6 +1429,9 @@ func (h *Handlers) newDeliveryViews(
ID: deliveries[i].ID, ID: deliveries[i].ID,
Status: deliveries[i].Status, Status: deliveries[i].Status,
Target: target.View, Target: target.View,
Replay: deliveries[i].Replay,
Created: humanize.Time(created),
CreatedUTC: created.UTC().Format(time.DateTime) + " UTC",
Results: results, Results: results,
AttemptCount: len(rows), AttemptCount: len(rows),
AttemptsOmitted: omitted, AttemptsOmitted: omitted,
+1 -2
View File
@@ -88,8 +88,7 @@ func (h *Handlers) deliveryPausedView(
} }
// newPausedView is a PausedView of deliveries paused until the given // newPausedView is a PausedView of deliveries paused until the given
// time. A time not on the current UTC day is written with its date, as // time. A time not on the current UTC day is written with its date.
// the event log writes its times.
func newPausedView(until time.Time) *PausedView { func newPausedView(until time.Time) *PausedView {
until = until.UTC() until = until.UTC()
+1
View File
@@ -9,6 +9,7 @@
<div class="rounded-md bg-white border border-gray-200 p-2"> <div class="rounded-md bg-white border border-gray-200 p-2">
<div class="flex flex-wrap items-center gap-3 text-xs"> <div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-gray-500">Attempt {{.AttemptNum}}</span> <span class="text-gray-500">Attempt {{.AttemptNum}}</span>
<span class="text-gray-500" title="{{.RanUTC}}">{{.Ran}}</span>
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if not .Success}}failure{{else if eq $.Target.Type "database"}}archived{{else if eq $.Target.Type "log"}}written to the log{{else}}success{{end}}</span> <span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if not .Success}}failure{{else if eq $.Target.Type "database"}}archived{{else if eq $.Target.Type "log"}}written to the log{{else}}success{{end}}</span>
{{/* A database or log target sends no HTTP request, so its attempts have no status code. */}} {{/* A database or log target sends no HTTP request, so its attempts have no status code. */}}
{{if not (eq $.Target.Type "database" "log")}} {{if not (eq $.Target.Type "database" "log")}}
+16
View File
@@ -0,0 +1,16 @@
{{define "delivery_row"}}
<!-- The line that heads a delivery, as handlers.DeliveryView holds it:
in the event log, inside the button that shows its attempts, and on
an event's own page. Spans only, since a button may hold no div. -->
<span class="flex flex-1 flex-wrap items-center justify-between gap-3">
<span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs text-gray-400" title="{{.CreatedUTC}}">created {{.Created}}</span>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
</span>
</span>
{{end}}
+2 -8
View File
@@ -21,7 +21,7 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt> <dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
<dd class="text-gray-900">{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC</dd> <dd class="text-gray-900">{{.ReceivedUTC}}</dd>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt> <dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
@@ -66,13 +66,7 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Deliveries}} {{range .Deliveries}}
<div class="p-4"> <div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-3"> {{template "delivery_row" .}}
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
</span>
</div>
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">
{{template "delivery_attempts" .}} {{template "delivery_attempts" .}}
</div> </div>
+4 -10
View File
@@ -33,10 +33,10 @@
<span class="flex flex-wrap items-center gap-4"> <span class="flex flex-wrap items-center gap-4">
{{range .Deliveries}} {{range .Deliveries}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}"> <span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">
{{.Target.DisplayName}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}} {{.Target.DisplayName}}{{if .Replay}} (replay){{end}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}}
</span> </span>
{{end}} {{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span> <span class="text-xs text-gray-400" title="{{.ReceivedUTC}}">{{.Received}}</span>
<!-- The caret has no text to select, so a click on it toggles at once. --> <!-- The caret has no text to select, so a click on it toggles at once. -->
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@@ -64,17 +64,11 @@
{{range .Deliveries}} {{range .Deliveries}}
<div class="py-2" x-data="collapsible"> <div class="py-2" x-data="collapsible">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-2 text-left" @click="toggle"> <button type="button" class="btn-small flex-1 gap-3 text-left" @click="toggle">
<span class="flex flex-wrap items-center gap-3"> {{template "delivery_row" .}}
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</span>
</button> </button>
{{if and .Status.Terminal (not .Target.Deleted)}} {{if and .Status.Terminal (not .Target.Deleted)}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">