3 Commits
Author SHA1 Message Date
clawbot 19978c6324 Show the last event as a relative time with a UTC tooltip (closes #567)
check / check (push) Waiting to run
The "Last event" figure in the webhook page's Statistics block now shows how long ago the newest event arrived, as its two largest units of d, h, m and s with no spaces ("59s", "1m1s", "3d4h"; a zero second unit is left off). It is worked out when the page is served, and a time ahead of the server clock shows "0s". Its tooltip is the full time in UTC. With no event it still shows "none", without a tooltip. A unit test pins each unit boundary, and the headless-browser test checks the figure and its tooltip.

Model: opus-5-5
2026-10-26 19:18:38 +01:00
clawbot eda8e80844 Fix six README sentences (closes #565)
check / check (push) Canceled after 0s
Rewrite the six sentences the documents check failed, each checked
against the code. The Sign-in limits section now opens with its rule.
The recovery routine's sentences say it resumes a delivery: it queues a
pending one at once, but gives a retrying one the rest of its wait.

Model: opus-5-5
2026-10-26 06:53:59 +01:00
clawbot e584718eb5 Commit Alpine.js to the repo instead of unpacking it at build time (closes #163)
check / check (push) Canceled after 0s
static/js/alpine.min.js is now committed: package/dist/cdn.min.js from
the @alpinejs/csp 3.14.9 npm package, byte for byte, taken once from the
package file that was in 3p/. The build no longer unpacks it, so
script/assets, the assets make target, its Dockerfile steps and the
ignore entries for the file are gone, and so is the package file.
Prettier and ESLint both ignore the file, so it stays as published.
Comments and the README now describe the committed file.

Model: opus-5-5
2026-10-26 05:16:05 +01:00
18 changed files with 215 additions and 98 deletions
+1 -3
View File
@@ -72,10 +72,8 @@
**/*.sublime-* **/*.sublime-*
# This repository's own host-built artifacts: the binary `make build` # This repository's own host-built artifacts: the binary `make build`
# writes, and the Alpine.js file `make assets` extracts from 3p/ (the # writes.
# build extracts its own).
/bin /bin
/static/js/alpine.min.js
# SQLite databases, which hold the session key and webhook payloads, at # SQLite databases, which hold the session key and webhook payloads, at
# any depth. # any depth.
-4
View File
@@ -77,7 +77,3 @@ data/
# Temporary files # Temporary files
tmp/ tmp/
temp/ temp/
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# what is committed.
/static/js/alpine.min.js
+1
View File
@@ -1,2 +1,3 @@
node_modules/ node_modules/
yarn.lock yarn.lock
static/js/alpine.min.js
Binary file not shown.
+2 -11
View File
@@ -92,10 +92,6 @@ COPY . .
# fmt-check` is the markdown-check stage above. # fmt-check` is the markdown-check stage above.
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# The golangci-lint steps run with --network=none. `golangci-lint config # The golangci-lint steps run with --network=none. `golangci-lint config
# verify` is documented as fetching its JSON schema over HTTPS; this pinned # verify` is documented as fetching its JSON schema over HTTPS; this pinned
# image resolves the schema without any network, and --network=none enforces # image resolves the schema without any network, and --network=none enforces
@@ -130,9 +126,6 @@ RUN go mod download
COPY . . COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/.
RUN script/assets
# -timeout applies to each package on its own, so 90s has only to clear the # -timeout applies to each package on its own, so 90s has only to clear the
# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most # slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most
# four test binaries build or run at once, each with at most eight parallel # four test binaries build or run at once, each with at most eight parallel
@@ -211,10 +204,8 @@ RUN version="$(make version VERSION="$VERSION")"; \
esac; \ esac; \
fi fi
# Builds through the Makefile's build target, which runs script/assets # The Makefile's build target builds with cgo off, so the binary is
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds # statically linked and runs on the Alpine runtime image below.
# with cgo off, so 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
+1 -1
View File
@@ -17,7 +17,7 @@ COPY . .
# The test binary embeds the templates and static files, so the browser # The test binary embeds the templates and static files, so the browser
# stage needs nothing else. It is built with cgo off and -p 4 keeps the # stage needs nothing else. It is built with cgo off and -p 4 keeps the
# compile's memory down, both as in the test phase of Dockerfile. # compile's memory down, both as in the test phase of Dockerfile.
RUN make assets && CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server RUN CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The # chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library # browser is on PATH as headless-shell, where the test's browser library
+3 -6
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version .PHONY: bootstrap setup test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
@@ -22,9 +22,6 @@ bootstrap:
setup: setup:
@script/setup @script/setup
assets:
@script/assets
test: test:
@script/test @script/test
@@ -47,13 +44,13 @@ check:
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or # REPO_POLICIES.md does: the binary carries no build paths, symbol table or
# debug information, and on Linux, the image's included, it is statically # debug information, and on Linux, the image's included, it is statically
# linked. # linked.
build: assets build:
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
dev: assets dev:
go run ./cmd/webhooker go run ./cmd/webhooker
deps: deps:
+24 -24
View File
@@ -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`, and Docker for the checks. You need the Go version named in `go.mod`.
```bash ```bash
git clone https://git.eeqj.de/sneak/webhooker.git git clone https://git.eeqj.de/sneak/webhooker.git
@@ -57,9 +57,8 @@ 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. On a host `/var/lib/webhooker`, so point it at a directory you can write to. `make build`
without Docker, `make build` writes the program to `bin/webhooker`, and running writes the program to `bin/webhooker`. Running that file starts webhooker.
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:
@@ -107,9 +106,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. The form also has an **HTTP target URL** field and an events forever. Filling in **HTTP target URL** creates an `http` target with the
**Archive** checkbox; each creates that target with the webhook. Every new webhook. Ticking **Archive** creates an archive target. Every new webhook gets
webhook gets one entrypoint, described as `Default entrypoint`. one entrypoint, described as `Default entrypoint`.
### Entrypoint URLs ### Entrypoint URLs
@@ -198,9 +197,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 that is loopback, the address only host is a host without Docker. On a bare host the private address is loopback
this host can reach. For the image it is the Docker bridge, the private network (`127.0.0.1`), which only this host can reach. For the image it is the Docker
Docker puts containers on. bridge, the private network Docker puts containers on.
Four things must be right. Four things must be right.
@@ -371,13 +370,13 @@ writes it. The reverse proxy's access log does hold it, in the request path.
### Sign-in limits ### Sign-in limits
The password is checked first, and only a failed sign-in is counted. A correct From the fifth failure within a minute, each failure is answered `429`. Only
password is never answered `429`. Failures are counted per client and submitted failures are counted. A correct password is never answered `429`. Failures are
username. From the fifth failure within a minute, each failure is answered counted per client and submitted username. At most two password checks run at
`429`. At most two password checks run at once; a request that gets no turn once; a request that gets no turn within 5 seconds is answered `503`. So
within 5 seconds is answered `503`. So guessing speed is bounded by the limit of guessing speed is bounded by the limit of two checks at a time, not by the
two checks at a time, not by the failure count. Choose a long random `admin` failure count. Choose a long random `admin` password, or rate-limit
password, or rate-limit `POST /pages/login` at the reverse proxy. `POST /pages/login` at the reverse proxy.
### Delivery destinations ### Delivery destinations
@@ -433,10 +432,11 @@ 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 queues again every delivery record each attempt. At start, a recovery routine resumes every delivery left
left `pending` or `retrying`. It then runs every minute. Each run sends again a `pending` or `retrying`. It then runs every minute. Each run resumes every
`retrying` delivery the engine is not working on, and a `pending` one that has `retrying` delivery that the engine is not already handling. It also resumes a
sat untouched for 15 minutes. So a stop or a crash loses no delivery. `pending` delivery that has sat untouched for 15 minutes. So a stop or a crash
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
@@ -689,8 +689,9 @@ checkout, or `VERSION` when that is set. It is shown at
The pages use one third-party script, Alpine.js. They load `@alpinejs/csp`, the The pages use one third-party script, Alpine.js. They load `@alpinejs/csp`, the
version of it that works when the Content-Security-Policy forbids running text version of it that works when the Content-Security-Policy forbids running text
as code. The npm registry is where JavaScript packages are published. The as code. The npm registry is where JavaScript packages are published.
package file in `3p/` is byte for byte the one the npm registry publishes. `static/js/alpine.min.js` is committed byte for byte as the npm registry
publishes it.
A headless browser is a browser without a window. A browser test in A headless browser is a browser without a window. A browser test in
`internal/server` loads the pages in one under the real Content-Security-Policy. `internal/server` loads the pages in one under the real Content-Security-Policy.
@@ -710,7 +711,6 @@ application are under [Entrypoint URLs](#entrypoint-urls).
- `script/bootstrap` installs git, make, Go and the Go modules. - `script/bootstrap` installs git, make, Go and the Go modules.
- `script/setup` runs `script/bootstrap`, then `script/install-precommit`. - `script/setup` runs `script/bootstrap`, then `script/install-precommit`.
- `script/projectname` prints `webhooker`. - `script/projectname` prints `webhooker`.
- `script/assets` extracts Alpine.js from `3p/` to `static/js/alpine.min.js`.
- `script/test` runs the tests in Docker. - `script/test` runs the tests in Docker.
- `script/test-browser` runs the browser test in Docker. - `script/test-browser` runs the browser test in Docker.
- `script/lint` runs `gofmt`, `golangci-lint` and ESLint in Docker. - `script/lint` runs `gofmt`, `golangci-lint` and ESLint in Docker.
+1 -1
View File
@@ -5,7 +5,7 @@
// REPO_POLICIES.md states that a linter can check: const for everything, // REPO_POLICIES.md states that a linter can check: const for everything,
// let only for a variable that is reassigned, never var. // let only for a variable that is reassigned, never var.
export default [ export default [
// Alpine.js, extracted from 3p/ by make assets; not ours to lint. // Alpine.js, committed as published; not ours to lint.
{ ignores: ["static/js/alpine.min.js"] }, { ignores: ["static/js/alpine.min.js"] },
{ {
// The pages load static/js/app.js as a classic script, not a module. // The pages load static/js/app.js as a classic script, not a module.
+6
View File
@@ -96,6 +96,12 @@ 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(
+54 -5
View File
@@ -15,6 +15,9 @@ 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
@@ -35,8 +38,12 @@ 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. // has. Retention does not change it. LastEvent is how long ago
// 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
@@ -81,6 +88,42 @@ 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
@@ -139,7 +182,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, stats) err := readTotals(db, now, stats)
if err != nil { if err != nil {
return err return err
} }
@@ -169,9 +212,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, from the running totals: the events' // when the last event arrived and how long before now that was, from
// row, and the targets' rows summed. // the running totals: the events' row, and the targets' rows summed.
func readTotals(db *gorm.DB, stats *WebhookStats) error { func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error {
var events database.EventTotals var events database.EventTotals
err := db.Take(&events).Error err := db.Take(&events).Error
@@ -190,6 +233,12 @@ func readTotals(db *gorm.DB, 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
+62 -17
View File
@@ -265,6 +265,13 @@ 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
@@ -300,14 +307,17 @@ func assertStatsPaneAfterPrune(
) { ) {
t.Helper() t.Helper()
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID)) page := renderSourceDetailPage(t, h, sess, hist.webhook.ID)
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC") pane := statsPane(t, page)
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+ // The newest event arrived moments ago, so the last event is a
"Targets 6 (2 active) "+ // few seconds ago.
"Deliveries in progress 13 "+ assert.Regexp(t, `Entrypoints 12 \(11 active\) `+
"Last event "+lastEvent+" "+ `Targets 6 \(2 active\) `+
"Retention 14 days") `Deliveries in progress 13 `+
`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 "+
@@ -440,15 +450,14 @@ 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))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) assert.Regexp(t, statsLastEventShown(arrived),
assert.Contains(t, pane, renderSourceDetailPage(t, h, sess, wh.ID))
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
} }
// TestWebhookStats_LastEventInUTC checks that the pane shows when the // TestWebhookStats_LastEventInUTC checks that the tooltip of the pane's
// last event arrived in UTC, as the event list does, when the time was // last event gives the time in UTC, as the event list does, when the
// stored in another zone, as it is on a host whose local time is not // time was stored in another zone, as it is on a host whose local time
// UTC. // is not UTC.
func TestWebhookStats_LastEventInUTC(t *testing.T) { func TestWebhookStats_LastEventInUTC(t *testing.T) {
t.Parallel() t.Parallel()
@@ -474,8 +483,9 @@ 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}))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) assert.Regexp(t,
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC") `Last event</span>\s*<span [^>]*title="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
@@ -541,8 +551,11 @@ 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())
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) page := 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))
} }
@@ -567,3 +580,35 @@ 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)
}
}
+38 -1
View File
@@ -61,7 +61,8 @@ 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,
// and that a target's row shows its delivery counts. // that a target's row shows its delivery counts, and that the
// Statistics block shows how long ago the last event arrived.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -97,6 +98,7 @@ 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")
@@ -831,6 +833,41 @@ 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
-3
View File
@@ -13,9 +13,6 @@ 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.
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
// file is not in the tree: this is the check that the page still gets
// the JavaScript it asks for.
func TestBaseTemplateScriptsAreServed(t *testing.T) { func TestBaseTemplateScriptsAreServed(t *testing.T) {
t.Parallel() t.Parallel()
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# forbids eval. The extracted file is not committed. make build, make dev
# and the Dockerfile's lint, test and build stages run this first.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
main "$@"
+11
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -6,9 +6,11 @@ import (
) )
// Static holds the CSS and JavaScript files the web UI's pages load. They // Static holds the CSS and JavaScript files the web UI's pages load. They
// are named one by one so that a missing js/alpine.min.js, which make // are named one by one, so a missing file fails the build instead of
// assets extracts and git does not track, fails the build instead of // leaving the pages without it.
// leaving the pages without Alpine.js. //
// js/alpine.min.js is Alpine.js, committed unchanged. It is
// package/dist/cdn.min.js from the npm package @alpinejs/csp 3.14.9.
// //
//go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js //go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js
var Static embed.FS var Static embed.FS
+5 -1
View File
@@ -22,7 +22,11 @@
</div> </div>
<div> <div>
<span class="text-gray-500">Last event</span> <span class="text-gray-500">Last event</span>
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span> {{if .LastEvent}}
<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>