Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f01f5628f |
No files matched your search
+3
-1
@@ -72,8 +72,10 @@
|
|||||||
**/*.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.
|
# writes, and the Alpine.js file `make assets` extracts from 3p/ (the
|
||||||
|
# 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.
|
||||||
|
|||||||
@@ -77,3 +77,7 @@ 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,3 +1,2 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
yarn.lock
|
yarn.lock
|
||||||
static/js/alpine.min.js
|
|
||||||
Binary file not shown.
+13
-5
@@ -92,6 +92,10 @@ 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
|
||||||
@@ -114,9 +118,8 @@ COPY --from=js-lint /src/yarn.lock /dev/null
|
|||||||
# depends on it.
|
# depends on it.
|
||||||
#
|
#
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17: the builder stage's
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17: the builder stage's
|
||||||
# image, which that stage needs for its git and make. Its test step runs
|
# image, which that stage needs for its git and make. This stage uses only
|
||||||
# under the image's bash, and the tests in internal/versionscript use its
|
# its Go.
|
||||||
# git, make and sh, and skip when they are missing.
|
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -126,6 +129,9 @@ 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
|
||||||
@@ -204,8 +210,10 @@ 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 runs script/assets
|
||||||
# statically linked and runs on the Alpine runtime image below.
|
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds
|
||||||
|
# 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
@@ -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 CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
RUN make assets && 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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
|
.PHONY: bootstrap setup assets 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,6 +22,9 @@ bootstrap:
|
|||||||
setup:
|
setup:
|
||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
|
assets:
|
||||||
|
@script/assets
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
@@ -44,13 +47,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:
|
build: assets
|
||||||
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:
|
dev: assets
|
||||||
go run ./cmd/webhooker
|
go run ./cmd/webhooker
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
|
|||||||
@@ -1,77 +1,72 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
One issue per unit of work, and one branch and one PR per issue:
|
One issue per unit of work, one branch and one PR per issue:
|
||||||
|
|
||||||
- make sure a tracked issue exists with a definition of done
|
- ensure a tracked issue exists with a definition of done
|
||||||
- branch from `next`, never from `main`
|
- branch from `next` (never from `main`)
|
||||||
- do the work and push the branch
|
- do the work; open a PR based on `next` (never on `main`)
|
||||||
- open a PR with base `next`, never `main`
|
- pass an independent review, then the manager squash-merges into `next`
|
||||||
- pass an independent review
|
- push; nothing stays local-only
|
||||||
- the repository manager squash-merges it into `next`
|
|
||||||
|
|
||||||
The repository manager is the role that merges each issue's PR into `next`.
|
`next` is the branch for the next milestone and must stay green and mergeable to
|
||||||
Nothing stays local-only: every commit is pushed.
|
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
||||||
|
releases are cut from `main` separately.
|
||||||
|
|
||||||
`next` is the branch for the next milestone. It must stay green and mergeable
|
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what
|
||||||
into `main` at all times. A milestone's work reaches `main` through one PR from
|
is done and what is under way. This file keeps no list of either.
|
||||||
`next`. The owner, @sneak, merges that PR. Cutting a release from `main` is a
|
|
||||||
separate step after that.
|
|
||||||
|
|
||||||
The tracker (https://git.eeqj.de/sneak/webhooker/issues) records what is done
|
|
||||||
and what is under way. This file keeps no list of either.
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
What is still open for 1.0.0 is in its milestone
|
What is still open for 1.0.0 is in its milestone
|
||||||
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged. Only
|
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and
|
||||||
@sneak tags it.
|
only @sneak tags it.
|
||||||
|
|
||||||
Delivery is at least once by design. When webhooker cannot record the outcome of
|
Delivery is at-least-once by design, not by accident: a send whose result row
|
||||||
a delivery, it sends that delivery again. The target can then receive the same
|
does not land is attempted again, so a receiver can see a duplicate. That is
|
||||||
event twice. The alternative is a lost delivery that nothing reports. The README
|
deliberate — the alternative is a silent lost delivery — and the README says so
|
||||||
says so. Such a duplicate is not a defect to re-file.
|
under Rationale. It is not a defect to re-file.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
|
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
|
||||||
into `main` is a separate act. It waits on neither the production run nor the
|
into `main` is a separate act from tagging and waits on neither of those: `next`
|
||||||
tag.
|
is kept mergeable at all times, which is the point of the branch.
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Per-webhook rate limits, set in the web UI and enforced at the entrypoint.
|
- Per-webhook rate limits, set in the web UI and enforced in the receiver
|
||||||
Today `RECEIVER_RATE_LIMIT` is the only rate-limit setting. It is how many
|
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT`
|
||||||
requests per minute one client address may send to one entrypoint. The same
|
sets for the whole server (see Rate Limiting in the README)
|
||||||
address is also capped at ten times that across all entrypoints together.
|
- API key authentication for programmatic access (the `APIKey` model exists, but
|
||||||
- API key authentication for programmatic access. The `APIKey` model and its
|
nothing creates a key and no middleware checks one)
|
||||||
`api_keys` table exist. Nothing creates a key and nothing checks one.
|
- REST API v1
|
||||||
- REST API v1. `/api/v1` is mounted with no routes today.
|
- CRUD for webhooks, entrypoints, targets
|
||||||
- CRUD for webhooks, entrypoints and targets
|
|
||||||
- event viewing and filtering endpoints
|
- event viewing and filtering endpoints
|
||||||
- event redelivery endpoint
|
- event redelivery endpoint
|
||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics in the web UI: response times summarised per webhook or per target,
|
- Analytics in the web UI: response times summarised per webhook or per target,
|
||||||
and figures for all webhooks together. Today the webhook page shows event,
|
and figures for all webhooks together. The webhook page already shows event,
|
||||||
delivery and failure counts and failure percentages. The target list counts
|
delivery and failure counts and failure percentages, the target list counts
|
||||||
each target's deliveries. The event log shows every attempt's duration.
|
each target's deliveries, the event log and each event's page show every
|
||||||
`/metrics` carries server-wide counts and delivery durations.
|
attempt's duration, and `/metrics` carries server-wide counts and delivery
|
||||||
- A remember-me option at login. Today every session survives a browser restart.
|
durations
|
||||||
It lasts up to 7 days. It ends sooner after `SESSION_IDLE_TIMEOUT` (default 24
|
- A remember-me option at login. Every session already survives a browser
|
||||||
hours) without use.
|
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
|
||||||
- Password reset over the web for a forgotten password. Today a signed-in user
|
(default 24 hours) without activity
|
||||||
can change their password. A lost password is reset from the command line with
|
- Password reset flow for a forgotten password over the web. The authenticated
|
||||||
`webhooker resetpw` (https://git.eeqj.de/sneak/webhooker/issues/208).
|
password _change_ flow already landed, and a lost password is recoverable from
|
||||||
|
the console with `webhooker resetpw`
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
- SNS and S3 delivery targets
|
- SNS and S3 delivery targets
|
||||||
- data transformations, such as a configurable Slack message format. The
|
- data transformations (e.g. a configurable Slack message format; the Slack
|
||||||
Slack target sends one fixed format today.
|
target sends one fixed format today)
|
||||||
- JSONL file delivery with periodic S3 upload. Today an archive target
|
- JSONL file delivery with periodic S3 upload (the `database` target already
|
||||||
writes events to SQLite files. An archive target can rotate those files
|
archives events to SQLite files, rotated hourly, daily or monthly if set,
|
||||||
hourly, daily or monthly. The webhook page offers them as one gzipped JSON
|
and the webhook page offers them as one gzipped JSON download)
|
||||||
download.
|
|
||||||
- webhook event search, and filtering beyond the event log's failed and
|
- webhook event search, and filtering beyond the event log's failed and
|
||||||
pending views
|
pending views
|
||||||
- multi-user with role-based access control. Today each webhook belongs to
|
- multi-user with role-based access control (each webhook already belongs to
|
||||||
one account. Only the account made at first boot exists. There is no way
|
one account, but there is only the account made at first boot and no way
|
||||||
to add another.
|
to add another)
|
||||||
+1
-1
@@ -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, committed as published; not ours to lint.
|
// Alpine.js, extracted from 3p/ by make assets; 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.
|
||||||
|
|||||||
@@ -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,9 @@ 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()
|
||||||
|
|
||||||
|
|||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/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 "$@"
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
(()=>{var it=!1,ot=!1,W=[],st=-1;function Wt(e){Mn(e)}function Mn(e){W.includes(e)||W.push(e),Nn()}function Gt(e){let t=W.indexOf(e);t!==-1&&t>st&&W.splice(t,1)}function Nn(){!ot&&!it&&(it=!0,queueMicrotask(kn))}function kn(){it=!1,ot=!0;for(let e=0;e<W.length;e++)W[e](),st=e;W.length=0,st=-1,ot=!1}var T,N,F,ct,at=!0;function Jt(e){at=!1,e(),at=!0}function Yt(e){T=e.reactive,F=e.release,N=t=>e.effect(t,{scheduler:r=>{at?Wt(r):r()}}),ct=e.raw}function lt(e){N=e}function Xt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),F(i))},i},()=>{t()}]}function Se(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>F(i)}var Zt=[],Qt=[],er=[];function tr(e){er.push(e)}function re(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Qt.push(t))}function Oe(e){Zt.push(e)}function Ce(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function ut(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function rr(e){for(e._x_effects?.forEach(Gt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ft=new MutationObserver(ht),dt=!1;function de(){ft.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),dt=!0}function pt(){Dn(),ft.disconnect(),dt=!1}var fe=[];function Dn(){let e=ft.takeRecords();fe.push(()=>e.length>0&&ht(e));let t=fe.length;queueMicrotask(()=>{if(fe.length===t)for(;fe.length>0;)fe.shift()()})}function m(e){if(!dt)return e();pt();let t=e();return de(),t}var mt=!1,Ae=[];function nr(){mt=!0}function ir(){mt=!1,ht(Ae),Ae=[]}function ht(e){if(mt){Ae=Ae.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ut(s,o)}),n.forEach((o,s)=>{Zt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Qt.forEach(s=>s(o));for(let o of t)o.isConnected&&er.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Te(e){return P(k(e))}function D(e,t,r){return e._x_dataStack=[t,...k(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function k(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?k(e.host):e.parentNode?k(e.parentNode):[]}function P(e){return new Proxy({objects:e},Pn)}var Pn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?In:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function In(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Re(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Me(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>$n(n,i),s=>_t(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function $n(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function _t(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),_t(e[t[0]],t.slice(1),r)}}var or={};function y(e,t){or[e]=t}function G(e,t){let r=Ln(t);return Object.entries(or).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=gt(e),n={interceptor:Me,...t};return re(e,r),n}function Ne(e,t,r,...n){try{return r(...n)}catch(i){ne(i,e,t)}}function ne(e,t,Line truncated
|
|
||||||
|
|
||||||
${r?'Expression: "'+r+`"
|
|
||||||
|
|
||||||
`:""}`,t),setTimeout(()=>{throw e},0)}var ke=!0;function De(e){let t=ke;ke=!1;let r=e();return ke=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=jn;function ar(e){sr=e}function jn(e,t){let r={};G(r,e);let n=[r,...k(e)],i=typeof t=="function"?yt(n,t):Bn(n,t,e);return Ne.bind(null,e,t,i)}function yt(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(P([n,...e]),i);ie(r,o)}}var xt={};function Fn(e,t){if(xt[e])return xt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return ne(s,t,e),Promise.resolve()}})();return xt[e]=o,o}function Bn(e,t,r){let n=Fn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=P([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>ne(l,r,t));n.finished?(ie(i,n.result,a,s,r),n.result=void 0):c.then(l=>{ie(i,l,a,s,r)}).catch(l=>ne(l,r,t)).finally(()=>n.result=void 0)}}}function ie(e,t,r,n,i){if(ke&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>ie(e,s,r,n)).catch(s=>ne(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var Et="x-";function C(e=""){return Et+e}function cr(e){Et=e}var Pe={};function d(e,t){return Pe[e]=t,{before(r){if(!Pe[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=J.indexOf(r);J.splice(n>=0?n:J.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(Pe).includes(e)}function me(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=vt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(Kn(n,r)).sort(Hn).map(o=>zn(e,o))}function vt(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var bt=!1,pe=new Map,ur=Symbol();function fr(e){bt=!0;let t=Symbol();ur=t,pe.set(t,[]);let r=()=>{for(;pe.get(t).length;)pe.get(t).shift()();pe.delete(t)},n=()=>{bt=!1,r()};e(r),n()}function gt(e){let t=[],r=a=>t.push(a),[n,i]=Xt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function zn(e,t){let r=()=>{},n=Pe[t.type]||r,[i,o]=gt(e);Ce(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),bt?pe.get(ur).push(n):n())};return s.runCleanups=o,s}var Ie=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),$e=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function oe(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${Et}([^:^.]+)\\b`);function Kn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var wt="DEFAULT",J=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",wt,"teleport"];function Hn(e,t){let r=J.indexOf(e.type)===-1?wt:e.type,n=J.indexOf(t.type)===-1?wt:t.type;return J.indexOf(r)-J.indexOf(n)}function Y(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)I(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),Y(document,"alpine:init"),Y(document,"alpine:initializing"),de(),tr(t=>S(t,I)),re(t=>$(t)),Oe((t,r)=>{me(t,r).forEach(n=>n())});let e=t=>!X(t.parentElement,!0);Array.from(document.querySelectorAll(br().join(","))).filter(e).forEach(t=>{S(t)}),Y(document,"alpine:initialized"),setTimeout(()=>{qn()})}var St=[],xr=[];function yr(){return St.map(e=>e())}function br(){return St.concat(xr).map(e=>e())}function Le(e){St.push(e)}function je(e){xr.push(e)}function X(e,t=!1){return B(e,r=>{if((t?br():yr()).some(i=>r.matches(i)))return!0})}function B(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return B(e.parentElement,t)}}function wr(Line truncated
|
|
||||||
|
|
||||||
"${t}"
|
|
||||||
|
|
||||||
Read more about the Alpine's CSP-friendly build restrictions here: https://alpinejs.dev/advanced/csp
|
|
||||||
|
|
||||||
`,e)}function Mt(e,t){let r=Object.create(null),n=e.split(",");for(let i=0;i<n.length;i++)r[n[i]]=!0;return t?i=>!!r[i.toLowerCase()]:i=>!!r[i]}var mi="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var qs=Mt(mi+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");var Yr=Object.freeze({}),Us=Object.freeze([]);var hi=Object.prototype.hasOwnProperty,be=(e,t)=>hi.call(e,t),V=Array.isArray,ae=e=>Xr(e)==="[object Map]";var _i=e=>typeof e=="string",Ue=e=>typeof e=="symbol",we=e=>e!==null&&typeof e=="object";var gi=Object.prototype.toString,Xr=e=>gi.call(e),Nt=e=>Xr(e).slice(8,-1);var We=e=>_i(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var Ge=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},xi=/-(\w)/g,Ws=Ge(e=>e.replace(xi,(t,r)=>r?r.toUpperCase():"")),yi=/\B([A-Z])/g,Gs=Ge(e=>e.replace(yi,"-$1").toLowerCase()),kt=Ge(e=>e.charAt(0).toUpperCase()+e.slice(1)),Js=Ge(e=>e?`on${kt(e)}`:""),Dt=(e,t)=>e!==t&&(e===e||t===t);var Pt=new WeakMap,Ee=[],j,ee=Symbol("iterate"),It=Symbol("Map key iterate");function bi(e){return e&&e._isEffect===!0}function nn(e,t=Yr){bi(e)&&(e=e.raw);let r=Ei(e,t);return t.lazy||r(),r}function on(e){e.active&&(sn(e),e.options.onStop&&e.options.onStop(),e.active=!1)}var wi=0;function Ei(e,t){let r=function(){if(!r.active)return e();if(!Ee.includes(r)){sn(r);try{return Si(),Ee.push(r),j=r,e()}finally{Ee.pop(),an(),j=Ee[Ee.length-1]}}};return r.id=wi++,r.allowRecurse=!!t.allowRecurse,r._isEffect=!0,r.active=!0,r.raw=e,r.deps=[],r.options=t,r}function sn(e){let{deps:t}=e;if(t.length){for(let r=0;r<t.length;r++)t[r].delete(e);t.length=0}}var ce=!0,Lt=[];function vi(){Lt.push(ce),ce=!1}function Si(){Lt.push(ce),ce=!0}function an(){let e=Lt.pop();ce=e===void 0?!0:e}function M(e,t,r){if(!ce||j===void 0)return;let n=Pt.get(e);n||Pt.set(e,n=new Map);let i=n.get(r);i||n.set(r,i=new Set),i.has(j)||(i.add(j),j.deps.push(i),j.options.onTrack&&j.options.onTrack({effect:j,target:e,type:t,key:r}))}function U(e,t,r,n,i,o){let s=Pt.get(e);if(!s)return;let a=new Set,c=u=>{u&&u.forEach(p=>{(p!==j||p.allowRecurse)&&a.add(p)})};if(t==="clear")s.forEach(c);else if(r==="length"&&V(e))s.forEach((u,p)=>{(p==="length"||p>=n)&&c(u)});else switch(r!==void 0&&c(s.get(r)),t){case"add":V(e)?We(r)&&c(s.get("length")):(c(s.get(ee)),ae(e)&&c(s.get(It)));break;case"delete":V(e)||(c(s.get(ee)),ae(e)&&c(s.get(It)));break;case"set":ae(e)&&c(s.get(ee));break}let l=u=>{u.options.onTrigger&&u.options.onTrigger({effect:u,target:e,key:r,type:t,newValue:n,oldValue:i,oldTarget:o}),u.options.scheduler?u.options.scheduler(u):u()};a.forEach(l)}var Ai=Mt("__proto__,__v_isRef,__isVue"),cn=new Set(Object.getOwnPropertyNames(Symbol).map(e=>Symbol[e]).filter(Ue)),Oi=ln();var Ci=ln(!0);var Zr=Ti();function Ti(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...r){let n=_(this);for(let o=0,s=this.length;o<s;o++)M(n,"get",o+"");let i=n[t](...r);return i===-1||i===!1?n[t](...r.map(_)):i}}),["push","pop","shift","unshift","splice"].forEach(t=>{e[t]=function(...r){vi();let n=_(this)[t].apply(this,r);return an(),n}}),e}function ln(e=!1,t=!1){return function(n,i,o){if(i==="__v_isReactive")return!e;if(i==="__v_isReadonly")return e;if(i==="__v_raw"&&o===(e?t?Vi:pn:t?Hi:dn).get(n))return n;let s=V(n);if(!e&&s&&be(Zr,i))return Reflect.get(Zr,i,o);let a=Reflect.get(n,i,o);return(Ue(i)?cn.has(i):Ai(i))||(e||M(n,"get",i),t)?a:$t(a)?!s||!We(i)?a.value:a:we(a)?e?mn(a):tt(a):a}}var Ri=Mi();function Mi(e=!1){return function(r,n,i,o){let s=r[n];if(!e&&(i=_(i),s=_(s),!V(r)&&$t(s)&&!$t(i)))return s.value=i,!0;let a=V(r)&&We(n)?Number(n)<r.length:be(r,n),c=Reflect.set(r,n,i,o);return r===_(o)&&(a?Dt(i,s)&&U(r,"set",n,i,s):U(r,"add",n,i)),c}}function Ni(e,t){let r=be(e,t),n=e[t],i=Reflect.deleteProperty(e,t);return i&&r&&U(e,"delete",t,void 0,n),i}function ki(e,t){let r=Reflect.has(e,t);return(!Ue(t)||!cn.has(t))&&M(e,"has",t),r}function Di(e){return M(e,"iterate",V(e)?"length":ee),Reflect.ownKeys(e)}var Pi={get:Oi,set:Ri,deleteProperty:Ni,has:ki,ownKeys:Di},Ii={get:Ci,set(e,t){return console.warn(`Set operation on key "${String(t)}" failed: target is readonly.`,e),!0},deleteProperty(e,t){return console.warn(`Delete operation on key "${String(t)}" failed: target is readonly.`,e),!0}};var jt=e=>we(e)?tt(e):e,Ft=e=>we(e)?mn(e):e,Bt=e=>e,et=e=>Reflect.getPrototypeOf(e);function Je(e,t,r=!1,n=!1){e=e.__v_raw;let i=_(e),o=_(t);t!==o&&!r&&M(i,"get",t),!r&&M(i,"get",o);let{has:s}=et(i),a=n?Bt:r?Ft:jt;if(s.call(i,t))return a(e.get(t));if(s.call(i,o))return a(e.get(o));e!==i&&e.get(t)}function Ye(e,t=!1){let r=this.__v_raw,n=_(r),i=_(e);return e!==i&&!t&&M(n,"has",e),!t&&M(n,"has",i),e===i?r.has(e):r.has(e)||r.has(i)}function Xe(e,t=!1){return e=e.__v_raw,!t&&M(_(e),"iterate",ee),Reflect.get(e,"size",e)}function Qr(e){e=_(e);let t=_(this);return et(t).has.call(t,e)||(t.add(e),U(t,"add",e,e)),this}function en(e,t){t
Line truncated
|
|
||||||
+3
-5
@@ -6,11 +6,9 @@ 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 a missing file fails the build instead of
|
// are named one by one so that a missing js/alpine.min.js, which make
|
||||||
// leaving the pages without it.
|
// assets extracts and git does not track, fails the build instead of
|
||||||
//
|
// 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
|
||||||
@@ -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