Compare commits
3
Commits
92dbfc8996
...
7199fcfa0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7199fcfa0b | ||
|
|
ea8384f4a2 | ||
|
|
17e6c85dd8 |
@@ -1364,17 +1364,18 @@ an entrypoint's Edit button shows its edit form in place of its description and
|
|||||||
hides until the form closes, Cancel hides the form and drops what was typed, as
|
hides until the form closes, Cancel hides the form and drops what was typed, as
|
||||||
does leaving the page and going back to it, and Save changes the description;
|
does leaving the page and going back to it, and Save changes the description;
|
||||||
of the recent events on the webhook page only the newest starts expanded, each
|
of the recent events on the webhook page only the newest starts expanded, each
|
||||||
expands and collapses, and Open leads to the event's own page; an event in the
|
expands and collapses, and Open leads to the event's own page; of the events in
|
||||||
event log expands and collapses when its row's caret or its ID is clicked, and
|
the event log only the newest starts expanded, and an event there expands and
|
||||||
from the keyboard, but not when its ID is selected with the mouse, and a
|
collapses when its row's caret or its ID is clicked, and from the keyboard, but
|
||||||
delivery's attempts inside it expand and collapse; and at phone width the menu
|
not when its ID is selected with the mouse, and a delivery's attempts inside it
|
||||||
button opens and closes the mobile menu. It also fails if the browser reports a
|
expand and collapse; and at phone width the menu button opens and closes the
|
||||||
console warning or error, an uncaught exception, or anything the policy refused.
|
mobile menu. It also fails if the browser reports a console warning or error, an
|
||||||
`make check` and the image build lint it but do not run it, and `make test`
|
uncaught exception, or anything the policy refused. `make check` and the image
|
||||||
leaves it out (its file is built only with the `browser` build tag). Run it with
|
build lint it but do not run it, and `make test` leaves it out (its file is
|
||||||
`make test-browser` after changing `templates/` or `static/js/`: that builds
|
built only with the `browser` build tag). Run it with `make test-browser` after
|
||||||
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
|
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
||||||
image, so the host needs no browser.
|
runs the test in a digest-pinned headless browser image, so the host needs no
|
||||||
|
browser.
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||||
@@ -1830,6 +1831,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.
|
||||||
@@ -1849,7 +1851,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
|
||||||
@@ -1903,6 +1906,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
|
||||||
@@ -2536,8 +2543,9 @@ The query string is never logged; it is replaced by the fixed marker
|
|||||||
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
|
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
|
||||||
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
||||||
buy the same amplification as an invented path. Nothing debuggable is
|
buy the same amplification as an invented path. Nothing debuggable is
|
||||||
lost: `page`, on the authenticated pagination links, is the only query
|
lost: the only query parameters this service reads are the login page's
|
||||||
parameter this service reads.
|
`next`, the page to return to, and `notice`, which names the line a page
|
||||||
|
shows after an action.
|
||||||
|
|
||||||
Client-supplied request content does not leave the host by the other
|
Client-supplied request content does not leave the host by the other
|
||||||
route either. The Sentry SDK attaches the request to every event it
|
route either. The Sentry SDK attaches the request to every event it
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -283,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 {
|
||||||
@@ -329,24 +329,15 @@ func replayBody(body string) *string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||||
// log it was triggered from, carrying the outcome as its notice and
|
// log it was triggered from, carrying the outcome as its notice.
|
||||||
// the page number the form submitted.
|
|
||||||
func redirectToEventLog(
|
func redirectToEventLog(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code noticeCode,
|
||||||
) {
|
) {
|
||||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
|
||||||
// The page is read from the form rather than the query string:
|
http.StatusSeeOther,
|
||||||
// this is a POST, and its query string is what logs and Referer
|
)
|
||||||
// headers record.
|
|
||||||
if page := pageOrFirst(
|
|
||||||
r.PostFormValue("page"),
|
|
||||||
); page > 1 {
|
|
||||||
dest += "&page=" + strconv.Itoa(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
|
|||||||
}).Error)
|
}).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
|
|
||||||
@@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
|
|||||||
stored := strings.Repeat("A", responseCap*4) + tail
|
stored := strings.Repeat("A", responseCap*4) + tail
|
||||||
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
require.Len(t, views[0].Deliveries[0].Results, 1)
|
require.Len(t, views[0].Deliveries[0].Results, 1)
|
||||||
|
|||||||
@@ -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>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ func seedAndProject(
|
|||||||
wh := seedWebhook(t, db)
|
wh := seedWebhook(t, db)
|
||||||
seedEventWithBody(t, dbMgr, wh.ID, body)
|
seedEventWithBody(t, dbMgr, wh.ID, body)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
|
|
||||||
return views[0]
|
return views[0]
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ const (
|
|||||||
SidecarLeftMsgForTest = sidecarLeftMsg
|
SidecarLeftMsgForTest = sidecarLeftMsg
|
||||||
)
|
)
|
||||||
|
|
||||||
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
|
||||||
// package.
|
|
||||||
func PageOrFirstForTest(s string) int {
|
|
||||||
return pageOrFirst(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DummyVerificationsForTest reports how many equivalent-cost
|
// DummyVerificationsForTest reports how many equivalent-cost
|
||||||
// verifications were charged for usernames that do not exist. It
|
// verifications were charged for usernames that do not exist. It
|
||||||
// lets a test prove the anti-enumeration path ran without timing
|
// lets a test prove the anti-enumeration path ran without timing
|
||||||
@@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte {
|
|||||||
func (s *Handlers) LoadEventLogViewsForTest(
|
func (s *Handlers) LoadEventLogViewsForTest(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
page int,
|
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, newRequestForTest(), webhook, nil, page,
|
w, newRequestForTest(), webhook, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||||
maxBodyShift = 20
|
maxBodyShift = 20
|
||||||
// recentEventLimit is the number of recent events to show.
|
// recentEventLimit is the number of most recent events that a
|
||||||
|
// webhook's page and its event log show.
|
||||||
recentEventLimit = 50
|
recentEventLimit = 50
|
||||||
// paginationPerPage is the number of items per page.
|
|
||||||
paginationPerPage = 25
|
|
||||||
|
|
||||||
// tmplKeyError is the template data key for an error message.
|
// tmplKeyError is the template data key for an error message.
|
||||||
tmplKeyError = "Error"
|
tmplKeyError = "Error"
|
||||||
@@ -166,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"),
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"source_logs.html",
|
"source_logs.html",
|
||||||
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
|
map[string]any{
|
||||||
|
dataKeyWebhook: webhook,
|
||||||
|
dataKeyEvents: []handlers.EventLogView{},
|
||||||
|
"TotalEvents": int64(0),
|
||||||
|
},
|
||||||
"Full Event Log - orders - Webhooker",
|
"Full Event Log - orders - Webhooker",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -275,3 +275,99 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
|
||||||
|
// delete prompt on the webhook page names the webhook, entrypoint or
|
||||||
|
// target and says what deleting it loses, that the webhook's gives its
|
||||||
|
// number of stored events (5 received, 2 removed by retention, so 3,
|
||||||
|
// the statistics pane's "Within retention" figure), and that an
|
||||||
|
// entrypoint with no description is named by its URL. The template
|
||||||
|
// writes the slashes after http: as \/, which the browser reads as /.
|
||||||
|
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(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)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, database.AddEventTotals(
|
||||||
|
webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
|
||||||
|
))
|
||||||
|
|
||||||
|
unnamed := seedEntrypoint(t, db, wh.ID)
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(
|
||||||
|
&database.Entrypoint{
|
||||||
|
WebhookID: wh.ID,
|
||||||
|
Path: "described-" + wh.ID,
|
||||||
|
Description: "Stripe",
|
||||||
|
Active: true,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete webhook "delete-me"?\n\n`+
|
||||||
|
`This deletes its stored events (3) and their deliveries. `+
|
||||||
|
`Any archive files it wrote are kept.`)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete entrypoint "Stripe"?\n\n`+
|
||||||
|
`Senders using its URL get an error from now on, `+
|
||||||
|
`and the URL cannot be restored.`)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete entrypoint "http:\/\/example.com/h/`+
|
||||||
|
unnamed.Path+`"?`)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete target "t-log"?\n\n`+
|
||||||
|
`Nothing more is delivered to it. `+
|
||||||
|
`Its past deliveries stay in the event log.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
||||||
|
// webhook name with quotes, a backslash, a closing script tag and a
|
||||||
|
// newline reaches its delete prompt escaped for the script, which the
|
||||||
|
// browser reads back as the name typed: each quote and angle bracket
|
||||||
|
// as a \u escape, the slash as \/, the newline as \n and the backslash
|
||||||
|
// doubled. An unescaped newline would break the prompt's script, and
|
||||||
|
// the form would then submit without asking.
|
||||||
|
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID,
|
||||||
|
Name: "Bob's \"best\" \\ hook</script>\nline two",
|
||||||
|
}
|
||||||
|
require.NoError(
|
||||||
|
t, db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, body,
|
||||||
|
"Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
|
||||||
|
"\\u003c\\/script\\u003e\\nline two"?")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package handlers_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -153,3 +156,55 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
|
|||||||
assert.Contains(t, body, tgt.Name)
|
assert.Contains(t, body, tgt.Name)
|
||||||
assert.Contains(t, body, "delivered")
|
assert.Contains(t, body, "delivered")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
|
||||||
|
// holds the 50 newest events, newest first, and not one more, and says
|
||||||
|
// how many events there are in all.
|
||||||
|
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for i := range 51 {
|
||||||
|
f.event(
|
||||||
|
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
|
||||||
|
base.Add(time.Duration(i)*time.Second),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
||||||
|
assert.NotContains(t, body, "application/x-log-00")
|
||||||
|
assert.Contains(t, body, "application/x-log-01")
|
||||||
|
assert.Less(
|
||||||
|
t,
|
||||||
|
strings.Index(body, "application/x-log-50"),
|
||||||
|
strings.Index(body, "application/x-log-49"),
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, "50 most recent of 51 events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
|
||||||
|
// events in the log only the newest starts expanded.
|
||||||
|
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
|
||||||
|
f.event(t, "application/x-newer", "{}", now)
|
||||||
|
|
||||||
|
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
||||||
|
|
||||||
|
open := strings.Index(body, " data-open>")
|
||||||
|
newer := strings.Index(body, "application/x-newer")
|
||||||
|
older := strings.Index(body, "application/x-older")
|
||||||
|
|
||||||
|
assert.Less(t, open, newer, "the newest event is not the open one")
|
||||||
|
assert.Less(t, newer, older)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -612,8 +621,9 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
|
|
||||||
// The host is the client's Host header, unvalidated. It is
|
// The host is the client's Host header, unvalidated. It is
|
||||||
// inert only because source_detail.html renders BaseURL as
|
// inert only because source_detail.html renders BaseURL as
|
||||||
// text inside a <code> element; putting it in an href or any
|
// text, inside a <code> element and in an entrypoint's delete
|
||||||
// other URL context needs it constrained first.
|
// prompt; putting it in an href or any other URL context
|
||||||
|
// needs it constrained first.
|
||||||
baseURL := scheme + "://" + r.Host
|
baseURL := scheme + "://" + r.Host
|
||||||
|
|
||||||
// The template calls Webhook methods, which take pointer
|
// The template calls Webhook methods, which take pointer
|
||||||
@@ -1112,30 +1122,17 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
page := h.parsePage(r)
|
|
||||||
|
|
||||||
evts, total, ok := h.loadEventsWithDeliveries(
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
w, r, webhook, targets, page,
|
w, r, webhook, targets,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages := int(total) / paginationPerPage
|
|
||||||
if int(total)%paginationPerPage != 0 {
|
|
||||||
totalPages++
|
|
||||||
}
|
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
"Events": evts,
|
"Events": evts,
|
||||||
"Page": page,
|
|
||||||
"TotalPages": totalPages,
|
|
||||||
"TotalEvents": total,
|
"TotalEvents": total,
|
||||||
"HasPrev": page > 1,
|
|
||||||
"HasNext": page < totalPages,
|
|
||||||
"PrevPage": page - 1,
|
|
||||||
"NextPage": page + 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_logs.html", data)
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
@@ -1195,15 +1192,11 @@ func (h *Handlers) loadTargetMap(
|
|||||||
return targetMap, nil
|
return targetMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePage extracts a page number from the query string.
|
// loadEventsWithDeliveries loads the recentEventLimit newest events
|
||||||
func (h *Handlers) parsePage(r *http.Request) int {
|
// and their deliveries from the per-webhook database, and the total
|
||||||
return pageOrFirst(r.URL.Query().Get("page"))
|
// number of events stored. Events come back as capped projections
|
||||||
}
|
// rather than database.Event rows: see eventLogColumns for why the
|
||||||
|
// cut happens in SQL.
|
||||||
// loadEventsWithDeliveries loads paginated events and their
|
|
||||||
// deliveries from the per-webhook database. Events come back
|
|
||||||
// as capped projections rather than database.Event rows: see
|
|
||||||
// eventLogColumns for why the cut happens in SQL.
|
|
||||||
//
|
//
|
||||||
// The bool reports whether the load succeeded. It is false
|
// The bool reports whether the load succeeded. It is false
|
||||||
// once this has answered the request with an error, and the
|
// once this has answered the request with an error, and the
|
||||||
@@ -1213,7 +1206,6 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
|
||||||
) ([]EventLogView, int64, bool) {
|
) ([]EventLogView, int64, bool) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
return nil, 0, true
|
return nil, 0, true
|
||||||
@@ -1228,9 +1220,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, totalEvents := loadEventLogRows(
|
rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
|
||||||
webhookDB, webhook.ID, page,
|
|
||||||
)
|
|
||||||
|
|
||||||
result, ok := h.eventLogViews(
|
result, ok := h.eventLogViews(
|
||||||
w, r, webhookDB, webhook.ID, rows, targetMap,
|
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||||
@@ -1303,10 +1293,11 @@ func (h *Handlers) eventLogViews(
|
|||||||
return result, true
|
return result, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadEventLogRows reads one page of the event log projection, newest
|
// loadEventLogRows reads the event log projection of the
|
||||||
// first, and the total number of events the pager counts against.
|
// recentEventLimit newest events, newest first, and the total number
|
||||||
|
// of events stored.
|
||||||
func loadEventLogRows(
|
func loadEventLogRows(
|
||||||
webhookDB *gorm.DB, webhookID string, page int,
|
webhookDB *gorm.DB, webhookID string,
|
||||||
) ([]eventLogRow, int64) {
|
) ([]eventLogRow, int64) {
|
||||||
var totalEvents int64
|
var totalEvents int64
|
||||||
|
|
||||||
@@ -1320,9 +1311,7 @@ func loadEventLogRows(
|
|||||||
eventLogColumns, maxRenderedBodyBytes,
|
eventLogColumns, maxRenderedBodyBytes,
|
||||||
).Where(
|
).Where(
|
||||||
"webhook_id = ?", webhookID,
|
"webhook_id = ?", webhookID,
|
||||||
).Order("created_at DESC").Offset(
|
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
|
||||||
(page - 1) * paginationPerPage,
|
|
||||||
).Limit(paginationPerPage).Find(&rows)
|
|
||||||
|
|
||||||
return rows, totalEvents
|
return rows, totalEvents
|
||||||
}
|
}
|
||||||
@@ -1331,9 +1320,9 @@ func loadEventLogRows(
|
|||||||
// events have been resubmitted from it.
|
// events have been resubmitted from it.
|
||||||
//
|
//
|
||||||
// One grouped query covers the page rather than one query per event.
|
// One grouped query covers the page rather than one query per event.
|
||||||
// A page holds paginationPerPage ids, far below SQLite's bound
|
// The page shows at most recentEventLimit events, far below SQLite's
|
||||||
// parameter ceiling, so it needs no chunking as the delivery result
|
// bound parameter ceiling, so it needs no chunking as the delivery
|
||||||
// load does.
|
// result load does.
|
||||||
func resubmitCounts(
|
func resubmitCounts(
|
||||||
webhookDB *gorm.DB, eventIDs []string,
|
webhookDB *gorm.DB, eventIDs []string,
|
||||||
) (map[string]int, error) {
|
) (map[string]int, error) {
|
||||||
@@ -1430,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,
|
||||||
@@ -1439,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,
|
||||||
@@ -1759,24 +1752,6 @@ func (h *Handlers) setTargetFromForm(
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageOrFirst parses a paginated page number, answering 1 for
|
|
||||||
// anything empty, unparseable or out of range.
|
|
||||||
//
|
|
||||||
// Falling back rather than rejecting is correct here and only here:
|
|
||||||
// a page number is where to send the browser next, not configuration
|
|
||||||
// the operator is storing, and the actions that submit one have
|
|
||||||
// already completed by the time it is read — answering 400 would
|
|
||||||
// report a failure that did not happen. Anything an operator SETS
|
|
||||||
// must be validated instead; see parseMaxRetries.
|
|
||||||
func pageOrFirst(s string) int {
|
|
||||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
|
||||||
if err != nil || v < 1 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetFormInput carries the raw values of a target form. Both the
|
// targetFormInput carries the raw values of a target form. Both the
|
||||||
// create and the edit path fill one and hand it to setTargetFromForm,
|
// create and the edit path fill one and hand it to setTargetFromForm,
|
||||||
// so neither can come to validate a target differently from the
|
// so neither can come to validate a target differently from the
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -383,20 +383,3 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
|
|
||||||
// non-numeric form value legitimately falls back. A page number says
|
|
||||||
// where to send the browser after an action that has already
|
|
||||||
// happened, so it is not configuration and rejecting it would report
|
|
||||||
// a failure that did not occur.
|
|
||||||
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
|
|
||||||
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
|
|
||||||
"%q should fall back to the first page", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
|
|
||||||
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
dataKeyWebhook = "Webhook"
|
dataKeyWebhook = "Webhook"
|
||||||
dataKeyError = "Error"
|
dataKeyError = "Error"
|
||||||
|
dataKeyEvents = "Events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testWebhookID is the identifier given to the webhook under test on
|
// testWebhookID is the identifier given to the webhook under test on
|
||||||
@@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
// A pointer, as in the handlers: source_detail.html calls
|
// A pointer, as in the handlers: source_detail.html calls
|
||||||
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
// Webhook.RetentionLabel, a pointer method. The webhook page only
|
||||||
// over their lists, and a list left out renders as empty, so the
|
// ranges over its lists, and a list left out renders as empty, so
|
||||||
// lists are left out.
|
// its lists are left out. The event log also counts its events, so
|
||||||
|
// it gets an empty list.
|
||||||
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||||
webhook.ID = testWebhookID
|
webhook.ID = testWebhookID
|
||||||
|
|
||||||
@@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
|
|
||||||
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||||
dataKeyWebhook: webhook,
|
dataKeyWebhook: webhook,
|
||||||
|
dataKeyEvents: []handlers.EventLogView{},
|
||||||
"TotalEvents": int64(0),
|
"TotalEvents": int64(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -318,7 +321,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
"Events": []database.Event{},
|
dataKeyEvents: []database.Event{},
|
||||||
"BaseURL": "https://hooks.example.com",
|
"BaseURL": "https://hooks.example.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -385,7 +388,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
"Events": []database.Event{},
|
dataKeyEvents: []database.Event{},
|
||||||
"BaseURL": "https://hooks.example.com",
|
"BaseURL": "https://hooks.example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -278,12 +278,13 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// after the '?'. Keeping the path and dropping the query is what makes
|
// after the '?'. Keeping the path and dropping the query is what makes
|
||||||
// this branch as bounded as the pattern branches below.
|
// this branch as bounded as the pattern branches below.
|
||||||
//
|
//
|
||||||
// Nothing debuggable is lost. One route in the service reads a query
|
// Nothing debuggable is lost. The only query parameters the service
|
||||||
// parameter at all — `page`, on the authenticated pagination links in
|
// reads are the login page's `next`, the page to return to, and
|
||||||
// internal/handlers/source_management.go — and the alternatives that
|
// `notice`, which names the line a page shows after an action. The
|
||||||
// would preserve more (a key count, a key allowlist) all require
|
// alternatives that would preserve more (a key count, a key
|
||||||
// parsing an attacker-sized query on every request, which is work an
|
// allowlist) all require parsing an attacker-sized query on every
|
||||||
// unauthenticated client would then be choosing for us.
|
// request, which is work an unauthenticated client would then be
|
||||||
|
// choosing for us.
|
||||||
func concreteLogURL(r *http.Request) string {
|
func concreteLogURL(r *http.Request) string {
|
||||||
path := r.URL.EscapedPath()
|
path := r.URL.EscapedPath()
|
||||||
|
|
||||||
|
|||||||
@@ -765,14 +765,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
|||||||
"the event's own page does not show its body")
|
"the event's own page does not show its body")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks an event's row. Clicking
|
// checkEventLog loads the event log and checks that of its events only
|
||||||
// its ID expands the event, and in there clicking its delivery shows the
|
// the newest, eventID, starts expanded: its row says so and its caret is
|
||||||
// delivery's attempts and clicking again hides them. Clicking the row's
|
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
||||||
// caret collapses the event, clicking it again expands it, and clicking
|
// the newest event, clicking its delivery shows the delivery's attempts
|
||||||
// the ID again collapses it. While the event is expanded the row says so
|
// and clicking again hides them. Clicking the row's caret collapses the
|
||||||
// and its caret is turned up, and while it is collapsed neither. It then
|
// event, clicking it again expands it, clicking its ID collapses it and
|
||||||
// runs checkEventSelection on the log's last event, lastEventID, and
|
// clicking the ID again expands it. While the event is collapsed the row
|
||||||
// checkEventKeyboard on eventID.
|
// says so and its caret is turned down. It then runs checkEventSelection
|
||||||
|
// on lastEventID and checkEventKeyboard on eventID.
|
||||||
func checkEventLog(
|
func checkEventLog(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
@@ -790,20 +791,19 @@ func checkEventLog(
|
|||||||
caretUp := caret + `[contains(@class, "rotate-180")]`
|
caretUp := caret + `[contains(@class, "rotate-180")]`
|
||||||
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
|
||||||
|
|
||||||
click(ctx, t, id)
|
|
||||||
assert.True(t, shown(ctx, expanded),
|
|
||||||
"clicking the event's ID does not expand it")
|
|
||||||
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
||||||
"the expanded event's row does not say it is expanded")
|
"the expanded event's row does not say it is expanded")
|
||||||
assert.True(t, shown(ctx, caretUp),
|
assert.True(t, shown(ctx, caretUp),
|
||||||
"the expanded event's caret does not turn up")
|
"the expanded event's caret does not turn up")
|
||||||
|
assert.True(t, hidden(ctx, lastExpanded),
|
||||||
|
"an older event starts expanded")
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||||
|
|
||||||
@@ -829,7 +829,11 @@ func checkEventLog(
|
|||||||
|
|
||||||
click(ctx, t, id)
|
click(ctx, t, id)
|
||||||
assert.True(t, hidden(ctx, expanded),
|
assert.True(t, hidden(ctx, expanded),
|
||||||
"clicking the event's ID again does not collapse it")
|
"clicking the event's ID does not collapse it")
|
||||||
|
|
||||||
|
click(ctx, t, id)
|
||||||
|
assert.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the event's ID again does not expand it")
|
||||||
|
|
||||||
checkEventSelection(ctx, t, url, lastEventID)
|
checkEventSelection(ctx, t, url, lastEventID)
|
||||||
checkEventKeyboard(ctx, t, url, eventID)
|
checkEventKeyboard(ctx, t, url, eventID)
|
||||||
@@ -878,8 +882,11 @@ func checkEventSelection(
|
|||||||
`window.getSelection().removeAllRanges()`, nil,
|
`window.getSelection().removeAllRanges()`, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The newest event starts expanded, which can push this one below
|
||||||
|
// the short window, where the mouse cannot reach it.
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
||||||
|
chromedp.ScrollIntoView(id, chromedp.BySearch),
|
||||||
))
|
))
|
||||||
|
|
||||||
selectText(ctx, t, id)
|
selectText(ctx, t, id)
|
||||||
@@ -922,8 +929,9 @@ func checkEventSelection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkEventKeyboard loads the event log and checks that Tab from the
|
// checkEventKeyboard loads the event log and checks that Tab from the
|
||||||
// page's Back link reaches the event's row, the first after it, and that
|
// page's Back link reaches the row of the newest event, the first after
|
||||||
// Enter then expands the event and Space collapses it.
|
// it, and that Enter then collapses that event, which starts expanded,
|
||||||
|
// and Space expands it again.
|
||||||
func checkEventKeyboard(
|
func checkEventKeyboard(
|
||||||
ctx context.Context, t *testing.T, url, eventID string,
|
ctx context.Context, t *testing.T, url, eventID string,
|
||||||
) {
|
) {
|
||||||
@@ -945,10 +953,10 @@ func checkEventKeyboard(
|
|||||||
"Tab from the Back link does not reach the event's row")
|
"Tab from the Back link does not reach the event's row")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
||||||
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
|
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
||||||
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
|
assert.True(t, shown(ctx, expanded), "Space does not expand the event")
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectText selects the text of the element matching an XPath
|
// selectText selects the text of the element matching an XPath
|
||||||
|
|||||||
@@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
|
|
||||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||||
// the tests above do not: the navbar's "Webhooks" links, the back and
|
// the tests above do not: the navbar's "Webhooks" links, the back and
|
||||||
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
// Cancel links, the list's link to a webhook, and the "Full Event Log"
|
||||||
// link beside the recent events, and the event log's page links. Each
|
// link beside the recent events. Each must point where it should, and
|
||||||
// must point where it should, and that page must render.
|
// that page must render.
|
||||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1392,12 +1392,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
wh := env.seedWebhook(t, userID)
|
wh := env.seedWebhook(t, userID)
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
tgt := env.seedTarget(t, wh.ID)
|
||||||
|
|
||||||
// The event log shows 25 events a page; one more gives it a second
|
|
||||||
// page, so it renders its Next and Previous links.
|
|
||||||
for range 26 {
|
|
||||||
env.seedEvent(t, wh.ID, "paged")
|
|
||||||
}
|
|
||||||
|
|
||||||
list := "/hooks"
|
list := "/hooks"
|
||||||
newForm := list + "/new"
|
newForm := list + "/new"
|
||||||
page := "/hook/" + wh.ID
|
page := "/hook/" + wh.ID
|
||||||
@@ -1429,8 +1423,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
{targetEdit, back, page},
|
{targetEdit, back, page},
|
||||||
{targetEdit, cancel, page},
|
{targetEdit, cancel, page},
|
||||||
{events, back, page},
|
{events, back, page},
|
||||||
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
|
||||||
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
|
||||||
} {
|
} {
|
||||||
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
||||||
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
||||||
|
|||||||
@@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
|||||||
|
|
||||||
// sentryKeepsHeader reports whether a request header is routing or
|
// sentryKeepsHeader reports whether a request header is routing or
|
||||||
// content metadata rather than client-chosen payload. Referer is kept
|
// content metadata rather than client-chosen payload. Referer is kept
|
||||||
// on the reasoning that it is browser-set, that this service emits
|
// on the reasoning that it is browser-set, that the only query
|
||||||
// only ?page= in its own links, and that Referrer-Policy is set to
|
// parameters in this service's own URLs are the login page's `next`
|
||||||
|
// and `notice`, and that Referrer-Policy is set to
|
||||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||||
// local access log line, which holds the rest of the detail.
|
// local access log line, which holds the rest of the detail.
|
||||||
func sentryKeepsHeader(name string) bool {
|
func sentryKeepsHeader(name string) bool {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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")}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
<!-- The delete prompts are the browser's own, so they
|
||||||
|
work without the page's scripts. The template
|
||||||
|
escapes each name for the script, so a quote or a
|
||||||
|
backslash in it shows as typed. -->
|
||||||
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook "{{.Webhook.Name}}"?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -249,7 +253,7 @@
|
|||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target "{{.Name}}"?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
<!-- Only the newest event starts expanded. -->
|
||||||
<div class="p-4" x-data="collapsible">
|
{{range $i, $event := .Events}}
|
||||||
|
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
|
||||||
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
||||||
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
||||||
<span class="flex flex-wrap items-center gap-3">
|
<span class="flex flex-wrap items-center gap-3">
|
||||||
@@ -32,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"/>
|
||||||
@@ -51,7 +52,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
|
||||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,22 +64,15 @@
|
|||||||
{{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">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
|
||||||
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -100,18 +93,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{{if or .HasPrev .HasNext}}
|
|
||||||
<div class="flex justify-center gap-2 mt-6">
|
|
||||||
{{if .HasPrev}}
|
|
||||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
|
||||||
{{end}}
|
|
||||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
|
||||||
{{if .HasNext}}
|
|
||||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user