Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e652a223a |
+3
-2
@@ -204,8 +204,9 @@ RUN version="$(make version VERSION="$VERSION")"; \
|
|||||||
esac; \
|
esac; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The Makefile's build target builds with cgo off, so the binary is
|
# Builds through the Makefile's build target, which builds with cgo off, so
|
||||||
# statically linked and runs on the Alpine runtime image below.
|
# the binary is statically linked and runs on the Alpine runtime image below
|
||||||
|
# as it is.
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ identifier of its own to the request, so make your targets tolerate a duplicate.
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
You need the Go version named in `go.mod`.
|
You need the Go version named in `go.mod`, and Docker for the checks.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||||
@@ -57,8 +57,9 @@ DATA_DIR=../webhooker-data make dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
`DATA_DIR` is where webhooker keeps its databases. It defaults to
|
`DATA_DIR` is where webhooker keeps its databases. It defaults to
|
||||||
`/var/lib/webhooker`, so point it at a directory you can write to. `make build`
|
`/var/lib/webhooker`, so point it at a directory you can write to. On a host
|
||||||
writes the program to `bin/webhooker`. Running that file starts webhooker.
|
without Docker, `make build` writes the program to `bin/webhooker`, and running
|
||||||
|
that file starts webhooker.
|
||||||
|
|
||||||
The first start creates the `admin` account and prints its password once, in a
|
The first start creates the `admin` account and prints its password once, in a
|
||||||
ruled box on standard output:
|
ruled box on standard output:
|
||||||
@@ -106,9 +107,9 @@ sooner after 24 hours without use, unless `SESSION_IDLE_TIMEOUT` says otherwise.
|
|||||||
On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional
|
On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional
|
||||||
**Description** and **Retention (days)**. Events older than the retention period
|
**Description** and **Retention (days)**. Events older than the retention period
|
||||||
are deleted, along with their deliveries. The default is 30 days. `0` keeps
|
are deleted, along with their deliveries. The default is 30 days. `0` keeps
|
||||||
events forever. Filling in **HTTP target URL** creates an `http` target with the
|
events forever. The form also has an **HTTP target URL** field and an
|
||||||
webhook. Ticking **Archive** creates an archive target. Every new webhook gets
|
**Archive** checkbox; each creates that target with the webhook. Every new
|
||||||
one entrypoint, described as `Default entrypoint`.
|
webhook gets one entrypoint, described as `Default entrypoint`.
|
||||||
|
|
||||||
### Entrypoint URLs
|
### Entrypoint URLs
|
||||||
|
|
||||||
@@ -197,9 +198,9 @@ Read it with `docker logs` and the name upaas gave the container.
|
|||||||
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
|
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
|
||||||
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse
|
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse
|
||||||
proxy holds the certificate and reaches webhooker over a private address. A bare
|
proxy holds the certificate and reaches webhooker over a private address. A bare
|
||||||
host is a host without Docker. On a bare host the private address is loopback
|
host is a host without Docker. On a bare host that is loopback, the address only
|
||||||
(`127.0.0.1`), which only this host can reach. For the image it is the Docker
|
this host can reach. For the image it is the Docker bridge, the private network
|
||||||
bridge, the private network Docker puts containers on.
|
Docker puts containers on.
|
||||||
|
|
||||||
Four things must be right.
|
Four things must be right.
|
||||||
|
|
||||||
@@ -370,13 +371,13 @@ writes it. The reverse proxy's access log does hold it, in the request path.
|
|||||||
|
|
||||||
### Sign-in limits
|
### Sign-in limits
|
||||||
|
|
||||||
From the fifth failure within a minute, each failure is answered `429`. Only
|
The password is checked first, and only a failed sign-in is counted. A correct
|
||||||
failures are counted. A correct password is never answered `429`. Failures are
|
password is never answered `429`. Failures are counted per client and submitted
|
||||||
counted per client and submitted username. At most two password checks run at
|
username. From the fifth failure within a minute, each failure is answered
|
||||||
once; a request that gets no turn within 5 seconds is answered `503`. So
|
`429`. At most two password checks run at once; a request that gets no turn
|
||||||
guessing speed is bounded by the limit of two checks at a time, not by the
|
within 5 seconds is answered `503`. So guessing speed is bounded by the limit of
|
||||||
failure count. Choose a long random `admin` password, or rate-limit
|
two checks at a time, not by the failure count. Choose a long random `admin`
|
||||||
`POST /pages/login` at the reverse proxy.
|
password, or rate-limit `POST /pages/login` at the reverse proxy.
|
||||||
|
|
||||||
### Delivery destinations
|
### Delivery destinations
|
||||||
|
|
||||||
@@ -432,11 +433,10 @@ sends them on in the background, and SQLite holds everything in between.
|
|||||||
|
|
||||||
The receiver stores the event and one pending delivery per active target in one
|
The receiver stores the event and one pending delivery per active target in one
|
||||||
database write, then queues the deliveries. Background workers run them and
|
database write, then queues the deliveries. Background workers run them and
|
||||||
record each attempt. At start, a recovery routine resumes every delivery left
|
record each attempt. At start, a recovery routine queues again every delivery
|
||||||
`pending` or `retrying`. It then runs every minute. Each run resumes every
|
left `pending` or `retrying`. It then runs every minute. Each run sends again a
|
||||||
`retrying` delivery that the engine is not already handling. It also resumes a
|
`retrying` delivery the engine is not working on, and a `pending` one that has
|
||||||
`pending` delivery that has sat untouched for 15 minutes. So a stop or a crash
|
sat untouched for 15 minutes. So a stop or a crash loses no delivery.
|
||||||
loses no delivery.
|
|
||||||
|
|
||||||
The main database holds configuration. Each webhook has its own event database,
|
The main database holds configuration. Each webhook has its own event database,
|
||||||
so writes for one webhook never wait on another webhook's database. The program
|
so writes for one webhook never wait on another webhook's database. The program
|
||||||
|
|||||||
@@ -96,12 +96,6 @@ func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
|||||||
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelativeTimeForTest exposes relativeTime for use in the
|
|
||||||
// handlers_test package.
|
|
||||||
func RelativeTimeForTest(at, now time.Time) string {
|
|
||||||
return relativeTime(at, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
||||||
// handlers_test package.
|
// handlers_test package.
|
||||||
func FinishedByTargetForTest(
|
func FinishedByTargetForTest(
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ const (
|
|||||||
longWindow = 24 * time.Hour
|
longWindow = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// day is the largest unit relativeTime writes.
|
|
||||||
const day = 24 * time.Hour
|
|
||||||
|
|
||||||
// percent turns a fraction into a percentage.
|
// percent turns a fraction into a percentage.
|
||||||
const percent = 100
|
const percent = 100
|
||||||
|
|
||||||
@@ -38,12 +35,8 @@ type WebhookStats struct {
|
|||||||
InProgress int64
|
InProgress int64
|
||||||
|
|
||||||
// LastEventAt is when the newest event arrived, or nil when none
|
// LastEventAt is when the newest event arrived, or nil when none
|
||||||
// has. Retention does not change it. LastEvent is how long ago
|
// has. Retention does not change it.
|
||||||
// that was, as relativeTime writes it, and LastEventUTC the full
|
|
||||||
// time; both are empty when no event has arrived.
|
|
||||||
LastEventAt *time.Time
|
LastEventAt *time.Time
|
||||||
LastEvent string
|
|
||||||
LastEventUTC string
|
|
||||||
|
|
||||||
Last10Minutes RecentWindow
|
Last10Minutes RecentWindow
|
||||||
Last24Hours RecentWindow
|
Last24Hours RecentWindow
|
||||||
@@ -88,42 +81,6 @@ func (w RecentWindow) FailurePercent() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// relativeTime is how long before now the time at was, in whole
|
|
||||||
// seconds, written as its two largest units out of d, h, m and s:
|
|
||||||
// "59s", "1m1s", "23h59m", "3d4h". A second unit of zero is left off,
|
|
||||||
// as in "5m". A time after now is "0s".
|
|
||||||
func relativeTime(at, now time.Time) string {
|
|
||||||
elapsed := max(now.Sub(at), 0)
|
|
||||||
|
|
||||||
days := int64(elapsed / day)
|
|
||||||
hours := int64(elapsed % day / time.Hour)
|
|
||||||
minutes := int64(elapsed % time.Hour / time.Minute)
|
|
||||||
seconds := int64(elapsed % time.Minute / time.Second)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case days > 0:
|
|
||||||
return twoUnits(days, "d", hours, "h")
|
|
||||||
case hours > 0:
|
|
||||||
return twoUnits(hours, "h", minutes, "m")
|
|
||||||
case minutes > 0:
|
|
||||||
return twoUnits(minutes, "m", seconds, "s")
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%ds", seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// twoUnits writes a count of a larger unit followed by a count of the
|
|
||||||
// next smaller one, leaving the smaller off when it is zero.
|
|
||||||
func twoUnits(
|
|
||||||
larger int64, largerUnit string, smaller int64, smallerUnit string,
|
|
||||||
) string {
|
|
||||||
if smaller == 0 {
|
|
||||||
return fmt.Sprintf("%d%s", larger, largerUnit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%d%s%d%s", larger, largerUnit, smaller, smallerUnit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadWebhookStats gathers the figures for the statistics pane from the
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
||||||
// webhook's entrypoints and targets, as the page has already loaded
|
// webhook's entrypoints and targets, as the page has already loaded
|
||||||
// them, and from its event database. It returns nil, and logs why, when
|
// them, and from its event database. It returns nil, and logs why, when
|
||||||
@@ -182,7 +139,7 @@ func (h *Handlers) loadWebhookStats(
|
|||||||
func readEventStats(
|
func readEventStats(
|
||||||
db *gorm.DB, now time.Time, stats *WebhookStats,
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
||||||
) error {
|
) error {
|
||||||
err := readTotals(db, now, stats)
|
err := readTotals(db, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -212,9 +169,9 @@ func readEventStats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readTotals fills in the lifetime and within-retention figures, and
|
// readTotals fills in the lifetime and within-retention figures, and
|
||||||
// when the last event arrived and how long before now that was, from
|
// when the last event arrived, from the running totals: the events'
|
||||||
// the running totals: the events' row, and the targets' rows summed.
|
// row, and the targets' rows summed.
|
||||||
func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error {
|
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
||||||
var events database.EventTotals
|
var events database.EventTotals
|
||||||
|
|
||||||
err := db.Take(&events).Error
|
err := db.Take(&events).Error
|
||||||
@@ -233,12 +190,6 @@ func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error {
|
|||||||
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
||||||
stats.LastEventAt = events.LastEventAt
|
stats.LastEventAt = events.LastEventAt
|
||||||
|
|
||||||
if events.LastEventAt != nil {
|
|
||||||
stats.LastEvent = relativeTime(*events.LastEventAt, now)
|
|
||||||
stats.LastEventUTC =
|
|
||||||
events.LastEventAt.UTC().Format(time.DateTime) + " UTC"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
stats.Lifetime.Deliveries += t.Deliveries
|
stats.Lifetime.Deliveries += t.Deliveries
|
||||||
stats.Lifetime.Failures += t.Failed
|
stats.Lifetime.Failures += t.Failed
|
||||||
|
|||||||
@@ -265,13 +265,6 @@ func statsPane(t *testing.T, page string) string {
|
|||||||
return strings.Join(strings.Fields(pane), " ")
|
return strings.Join(strings.Fields(pane), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// statsLastEventShown matches the pane's last event for an event that
|
|
||||||
// arrived at at: a figure with the full time in UTC as its tooltip.
|
|
||||||
func statsLastEventShown(at time.Time) string {
|
|
||||||
return `Last event</span>\s*<span [^>]*title="` +
|
|
||||||
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
||||||
// each target's totals and its deliveries finished in the last 24
|
// each target's totals and its deliveries finished in the last 24
|
||||||
// hours. The first target has ten deliveries and the replay, the
|
// hours. The first target has ten deliveries and the replay, the
|
||||||
@@ -307,17 +300,14 @@ func assertStatsPaneAfterPrune(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
page := renderSourceDetailPage(t, h, sess, hist.webhook.ID)
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||||
pane := statsPane(t, page)
|
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||||
|
|
||||||
// The newest event arrived moments ago, so the last event is a
|
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
|
||||||
// few seconds ago.
|
"Targets 6 (2 active) "+
|
||||||
assert.Regexp(t, `Entrypoints 12 \(11 active\) `+
|
"Deliveries in progress 13 "+
|
||||||
`Targets 6 \(2 active\) `+
|
"Last event "+lastEvent+" "+
|
||||||
`Deliveries in progress 13 `+
|
"Retention 14 days")
|
||||||
`Last event \d+s `+
|
|
||||||
`Retention 14 days`, pane)
|
|
||||||
assert.Regexp(t, statsLastEventShown(hist.newest.CreatedAt), page)
|
|
||||||
assert.Contains(t, pane, "Lifetime Within retention "+
|
assert.Contains(t, pane, "Lifetime Within retention "+
|
||||||
"Events 10 9 "+
|
"Events 10 9 "+
|
||||||
"Deliveries 21 18 "+
|
"Deliveries 21 18 "+
|
||||||
@@ -450,14 +440,15 @@ func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
|||||||
require.NotNil(t, stats.LastEventAt)
|
require.NotNil(t, stats.LastEventAt)
|
||||||
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
||||||
|
|
||||||
assert.Regexp(t, statsLastEventShown(arrived),
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
renderSourceDetailPage(t, h, sess, wh.ID))
|
assert.Contains(t, pane,
|
||||||
|
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebhookStats_LastEventInUTC checks that the tooltip of the pane's
|
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
||||||
// last event gives the time in UTC, as the event list does, when the
|
// last event arrived in UTC, as the event list does, when the time was
|
||||||
// time was stored in another zone, as it is on a host whose local time
|
// stored in another zone, as it is on a host whose local time is not
|
||||||
// is not UTC.
|
// UTC.
|
||||||
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -483,9 +474,8 @@ func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
|||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||||
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
||||||
|
|
||||||
assert.Regexp(t,
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
`Last event</span>\s*<span [^>]*title="2026-03-05 03:30:00 UTC">`,
|
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
||||||
renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
||||||
@@ -551,11 +541,8 @@ func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
|||||||
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
||||||
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
||||||
|
|
||||||
page := renderSourceDetailPage(t, h, sess, wh.ID)
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
pane := statsPane(t, page)
|
|
||||||
assert.Contains(t, pane, "Last event none")
|
assert.Contains(t, pane, "Last event none")
|
||||||
assert.Regexp(t, `Last event</span>\s*<span class="[^"]*">none</span>`,
|
|
||||||
page, "a last event of none has a tooltip")
|
|
||||||
assert.Contains(t, pane, "Failure percentage — —")
|
assert.Contains(t, pane, "Failure percentage — —")
|
||||||
assert.False(t, dbMgr.DBExists(wh.ID))
|
assert.False(t, dbMgr.DBExists(wh.ID))
|
||||||
}
|
}
|
||||||
@@ -580,35 +567,3 @@ func TestRecentWindow_FailurePercent(t *testing.T) {
|
|||||||
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRelativeTime pins how the pane writes the time since the last
|
|
||||||
// event, at each boundary between its units.
|
|
||||||
func TestRelativeTime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Date(2026, time.October, 26, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
ago time.Duration
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{0, "0s"},
|
|
||||||
{59900 * time.Millisecond, "59s"},
|
|
||||||
{60 * time.Second, "1m"},
|
|
||||||
{61 * time.Second, "1m1s"},
|
|
||||||
{3599 * time.Second, "59m59s"},
|
|
||||||
{3600 * time.Second, "1h"},
|
|
||||||
{3660 * time.Second, "1h1m"},
|
|
||||||
{86399 * time.Second, "23h59m"},
|
|
||||||
{86400 * time.Second, "1d"},
|
|
||||||
{90000 * time.Second, "1d1h"},
|
|
||||||
{400*24*time.Hour + 3*time.Hour, "400d3h"},
|
|
||||||
// An event time ahead of the server clock.
|
|
||||||
{-90 * time.Second, "0s"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
assert.Equal(t, tt.want,
|
|
||||||
handlers.RelativeTimeForTest(now.Add(-tt.ago), now), tt.ago)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ const (
|
|||||||
// event log in a headless browser, served by the real router and so
|
// event log in a headless browser, served by the real router and so
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
// Alpine.js directives, the copy control and the delete prompts work,
|
// Alpine.js directives, the copy control and the delete prompts work,
|
||||||
// that a target's row shows its delivery counts, and that the
|
// and that a target's row shows its delivery counts.
|
||||||
// Statistics block shows how long ago the last event arrived.
|
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -98,7 +97,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkCopy(ctx, t, page)
|
checkCopy(ctx, t, page)
|
||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
checkRecentEvents(ctx, t, page)
|
checkRecentEvents(ctx, t, page)
|
||||||
checkStatsLastEvent(ctx, t, env, webhook.ID, page)
|
|
||||||
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
||||||
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
||||||
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
||||||
@@ -833,41 +831,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkStatsLastEvent records that the last event of the webhook
|
|
||||||
// webhookID arrived 3 days, 4 hours and 30 minutes ago, loads its page,
|
|
||||||
// and checks that the Statistics block shows the last event as "3d4h"
|
|
||||||
// with the full time in UTC as its tooltip.
|
|
||||||
func checkStatsLastEvent(
|
|
||||||
ctx context.Context, t *testing.T, env *testEnv, webhookID, url string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ago := 3*24*time.Hour + 4*time.Hour + 30*time.Minute
|
|
||||||
arrived := time.Now().Add(-ago)
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{LastEventAt: &arrived}))
|
|
||||||
|
|
||||||
figure := `//span[text()="Last event"]` +
|
|
||||||
`/following-sibling::span[text()="3d4h"]`
|
|
||||||
|
|
||||||
var (
|
|
||||||
title string
|
|
||||||
found bool
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
require.True(t, shown(ctx, figure),
|
|
||||||
`the Statistics block does not show the last event as "3d4h"`)
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.AttributeValue(
|
|
||||||
figure, "title", &title, &found, chromedp.BySearch,
|
|
||||||
)))
|
|
||||||
assert.Equal(t, arrived.UTC().Format(time.DateTime)+" UTC", title,
|
|
||||||
"the last event's tooltip is not the full time in UTC")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks that of its events only
|
// checkEventLog loads the event log and checks that of its events only
|
||||||
// the newest, eventID, starts expanded: its row says so and its caret is
|
// the newest, eventID, starts expanded: its row says so and its caret is
|
||||||
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||||
// template loads on each page and fetches it through the real router.
|
// template loads on each page and fetches it through the real router.
|
||||||
|
// This is the check that each page gets the JavaScript it asks for.
|
||||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Last event</span>
|
<span class="text-gray-500">Last event</span>
|
||||||
{{if .LastEvent}}
|
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||||
<span class="font-medium text-gray-900" title="{{.LastEventUTC}}">{{.LastEvent}}</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="font-medium text-gray-900">none</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Retention</span>
|
<span class="text-gray-500">Retention</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user