Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51186b347b |
+4
-2
@@ -3,8 +3,10 @@
|
||||
# stage of the Dockerfile.
|
||||
.git/
|
||||
bin/
|
||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||
# needed. The tarball in 3p/ must stay in the context.
|
||||
# Third-party browser assets are fetched and hash-verified inside the build by
|
||||
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
||||
# from supplying the bytes that get shipped. The script and its
|
||||
# static/vendor.sha256 manifest stay in the context.
|
||||
static/js/alpine.min.js
|
||||
*.md
|
||||
LICENSE
|
||||
|
||||
+3
-2
@@ -46,6 +46,7 @@ temp/
|
||||
# CI cache barrier, written into the build context by the check workflow
|
||||
.ci-fingerprint
|
||||
|
||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||
# what is committed.
|
||||
# Third-party browser assets, fetched and hash-verified by
|
||||
# script/fetch-assets against static/vendor.sha256. Not committed:
|
||||
# REPO_POLICIES.md forbids minified bundles in version control.
|
||||
/static/js/alpine.min.js
|
||||
Binary file not shown.
+11
-4
@@ -51,8 +51,15 @@ RUN go mod download
|
||||
# the lint stage above.
|
||||
COPY . .
|
||||
|
||||
# Run tests and build. Both first run script/assets, which extracts Alpine.js
|
||||
# from its tarball in 3p/.
|
||||
# Fetch the third-party browser assets the UI serves. They are not committed
|
||||
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
||||
# .dockerignore keeps any host copy out of the build context, so this step is
|
||||
# the only way they enter the image. Each download is checked against a
|
||||
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
|
||||
# hashes against the bytes go:embed actually put in the binary.
|
||||
RUN script/fetch-assets
|
||||
|
||||
# Run tests and build
|
||||
RUN make test
|
||||
|
||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||
@@ -60,8 +67,8 @@ RUN make test
|
||||
# host and passes it in. The default is what a bare `docker build .`
|
||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||
#
|
||||
# Declared here, below the test step, so a changed version does not
|
||||
# invalidate its cached layer.
|
||||
# Declared here, below the test and asset steps, so a changed version
|
||||
# does not invalidate their cached layers.
|
||||
ARG VERSION=unknown
|
||||
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
@@ -28,7 +28,7 @@ setup:
|
||||
@script/setup
|
||||
|
||||
assets:
|
||||
@script/assets
|
||||
@script/fetch-assets
|
||||
|
||||
test:
|
||||
@script/test
|
||||
@@ -45,13 +45,13 @@ fmt-check:
|
||||
check:
|
||||
@script/check
|
||||
|
||||
build: assets
|
||||
build:
|
||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
dev: assets
|
||||
dev:
|
||||
go run ./cmd/webhooker
|
||||
|
||||
deps:
|
||||
|
||||
@@ -21,6 +21,9 @@ before deploying one.
|
||||
- Go 1.26.1+ (the version in `go.mod`)
|
||||
- Docker (for linting, for the test stage of the CI gate, and for
|
||||
containerized deployment)
|
||||
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||
browser assets, which are not committed (`make bootstrap` installs
|
||||
it if missing)
|
||||
|
||||
golangci-lint is not a prerequisite and must not be installed on the
|
||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||
@@ -33,7 +36,9 @@ digest-pinned linter image via `Dockerfile.lint`.
|
||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||
cd webhooker
|
||||
|
||||
# Install the Go toolchain if missing, and the Go dependencies
|
||||
# Install Go dependencies and the third-party browser assets.
|
||||
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||
# and the checks below need the fetched assets.
|
||||
make bootstrap
|
||||
|
||||
# Run all checks (test, lint, format check)
|
||||
@@ -53,7 +58,7 @@ make docker
|
||||
```bash
|
||||
make bootstrap # Install all dependencies (idempotent)
|
||||
make setup # Bootstrap + install git pre-commit hook
|
||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
||||
make assets # Fetch + verify third-party browser assets
|
||||
make fmt # Format code (gofmt + goimports)
|
||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
@@ -1249,14 +1254,14 @@ This repository adheres to the
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||
`version` are inline commands with no script behind them, though `build`
|
||||
and `version` both take their value from `script/version`.
|
||||
`version` are inline commands with no script behind them, though
|
||||
`build` and `version` both take their value from `script/version`.
|
||||
|
||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||
[Third-party browser assets](#third-party-browser-assets)), so
|
||||
`make test`, `make check` and the pre-commit hook work on a fresh clone
|
||||
without a separate step.
|
||||
`make check` needs the third-party browser assets in `static/`, which
|
||||
are not committed, so run `make bootstrap` (or just `make assets`) once
|
||||
after cloning. Without them the tests fail with a message naming that
|
||||
remedy. `make check` does not fetch them itself because it must not
|
||||
change any files in the repo.
|
||||
|
||||
We provide:
|
||||
|
||||
@@ -1264,8 +1269,8 @@ We provide:
|
||||
- `script/setup` — make a fresh clone ready for development
|
||||
(bootstrap, then install-precommit)
|
||||
- `script/projectname` — output the project name ("webhooker")
|
||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||
[Third-party browser assets](#third-party-browser-assets))
|
||||
- `script/fetch-assets` — download the third-party browser assets into
|
||||
`static/`, verifying each against its pinned sha256
|
||||
- `script/test` — run the test suite
|
||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||
- `script/fmt` — format all code (writes)
|
||||
@@ -1287,25 +1292,24 @@ We provide:
|
||||
|
||||
## Third-party browser assets
|
||||
|
||||
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||
publishes it. It is a dependency, not this repo's build output, so
|
||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
`vendor/` directory as its module vendor directory.
|
||||
The web UI serves one third-party script, Alpine.js. It is **not** committed:
|
||||
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
||||
both committed build artifacts and unpinned external references.
|
||||
|
||||
`script/assets` (`make assets`) extracts the browser build,
|
||||
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
|
||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||
`.dockerignore` keeps any host copy out of the build context.
|
||||
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
||||
download against a hardcoded sha256, and installs it under `static/`. The
|
||||
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
||||
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
||||
against that manifest — so the pin is enforced on what actually ships, not
|
||||
merely written down. Any mismatch fails the build.
|
||||
|
||||
To move to a new version: download
|
||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||
against the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||
with it, update its file name in `script/assets`, and run `make check`.
|
||||
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
||||
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
||||
artifact out of both the repo and the build context.
|
||||
|
||||
To move to a new version: update the version, URL, and tarball sha256 in
|
||||
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
||||
run `make assets && make check`.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -2784,8 +2788,6 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
||||
|
||||
```
|
||||
webhooker/
|
||||
├── 3p/
|
||||
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||
├── cmd/webhooker/
|
||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||
├── internal/
|
||||
@@ -2879,7 +2881,8 @@ webhooker/
|
||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||
│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
|
||||
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
|
||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
@@ -3187,14 +3190,14 @@ version is fixed independently of the compiler's:
|
||||
`make fmt-check`, then `golangci-lint config verify` and
|
||||
`golangci-lint run`, both with `--network=none`.
|
||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||
stage passing (it copies a file from it), runs `make test` and
|
||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||
runs on musl. Both builds go through `make build`, the relink adding
|
||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||
stamps the version. The version arrives as the `VERSION` build arg,
|
||||
since the context has no `.git` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||
to download and verify the third-party browser assets, then runs
|
||||
`make test` and `make build`, and finally rebuilds the binary with
|
||||
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
||||
go through `make build`, the relink adding its `-extldflags` via
|
||||
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||
The version arrives as the `VERSION` build arg, since the context
|
||||
has no `.git` (see [Version stamping](#version-stamping)).
|
||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary,
|
||||
creates the `/var/lib/webhooker` directory for all SQLite databases,
|
||||
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/getsentry/sentry-go v0.25.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.1
|
||||
@@ -29,7 +30,6 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
@@ -25,7 +27,7 @@ const maxRenderedResponseBytes = 4096
|
||||
// cut, so an oversized stored response never becomes a Go
|
||||
// string at all.
|
||||
const deliveryResultColumns = "delivery_id, attempt_num, success, " +
|
||||
"status_code, error, duration, " +
|
||||
"status_code, error, duration, created_at, " +
|
||||
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
||||
"length(cast(response_body as blob)) AS response_bytes"
|
||||
|
||||
@@ -106,6 +108,10 @@ type deliveryResultRow struct {
|
||||
Duration int64
|
||||
ResponseBody []byte
|
||||
ResponseBytes int64
|
||||
|
||||
// CreatedAt is when the attempt's result was recorded, which
|
||||
// is when the attempt finished.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// view projects a loaded row for rendering, stripping the
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||
maxBodyShift = 20
|
||||
// recentEventLimit is the number of recent events to show.
|
||||
recentEventLimit = 20
|
||||
recentEventLimit = 50
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// recentEventColumns is the recent events list's projection. It
|
||||
// reads the body's size and never the body itself, for the reason
|
||||
// maxRenderedBodyBytes gives; the cast to blob makes length count
|
||||
// bytes rather than characters.
|
||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||
"resubmitted_from_id, length(cast(body as blob)) AS body_bytes"
|
||||
|
||||
// RecentEventView is one row of the recent events list on a
|
||||
// webhook's page.
|
||||
type RecentEventView struct {
|
||||
Method string
|
||||
ContentType string
|
||||
|
||||
// ResubmittedFromID names the event this one was copied from,
|
||||
// empty for an event that arrived on the receiver.
|
||||
ResubmittedFromID string
|
||||
|
||||
// Received is how long ago the event arrived, and ReceivedUTC
|
||||
// the full timestamp the page shows on hover.
|
||||
Received string
|
||||
ReceivedUTC string
|
||||
|
||||
// Size is the size of the stored body.
|
||||
Size string
|
||||
|
||||
// ProcessingTime is how long the event's slowest delivery
|
||||
// took; see processingTime.
|
||||
ProcessingTime string
|
||||
|
||||
// Status is what the webhook's HTTP target answered, and
|
||||
// StatusClass its colour; see targetStatus. Both are empty
|
||||
// unless the webhook has exactly one HTTP target.
|
||||
Status string
|
||||
StatusClass string
|
||||
}
|
||||
|
||||
// recentEventRow is one row of recentEventColumns.
|
||||
type recentEventRow struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Method string
|
||||
ContentType string
|
||||
ResubmittedFromID *string
|
||||
BodyBytes uint64
|
||||
}
|
||||
|
||||
// singleHTTPTargetID returns the ID of the webhook's HTTP target
|
||||
// when it has exactly one, and "" when it has none or several.
|
||||
func singleHTTPTargetID(targets []database.Target) string {
|
||||
id := ""
|
||||
count := 0
|
||||
|
||||
for i := range targets {
|
||||
if targets[i].Type == database.TargetTypeHTTP {
|
||||
id = targets[i].ID
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// loadRecentEvents loads the webhook's recentEventLimit newest
|
||||
// events for its page, newest first. statusTargetID is the
|
||||
// webhook's only HTTP target, or "" when the list shows no status.
|
||||
func (h *Handlers) loadRecentEvents(
|
||||
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
||||
) ([]RecentEventView, error) {
|
||||
var rows []recentEventRow
|
||||
|
||||
err := webhookDB.Model(&database.Event{}).
|
||||
Select(recentEventColumns).
|
||||
Where("webhook_id = ?", webhookID).
|
||||
Order("created_at DESC").
|
||||
Limit(recentEventLimit).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eventIDs := make([]string, len(rows))
|
||||
for i := range rows {
|
||||
eventIDs[i] = rows[i].ID
|
||||
}
|
||||
|
||||
// Oldest first, so an event's last delivery to a target is its
|
||||
// newest: a replay adds a delivery rather than changing the
|
||||
// earlier one.
|
||||
var deliveries []database.Delivery
|
||||
|
||||
err = webhookDB.
|
||||
Select("id, event_id, target_id, status, created_at").
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Order("created_at ASC").
|
||||
Find(&deliveries).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byEvent := make(map[string][]database.Delivery, len(rows))
|
||||
deliveryIDs := make([]string, len(deliveries))
|
||||
|
||||
for i := range deliveries {
|
||||
eventID := deliveries[i].EventID
|
||||
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
|
||||
deliveryIDs[i] = deliveries[i].ID
|
||||
}
|
||||
|
||||
attempts, err := h.loadDeliveryResults(webhookDB, deliveryIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]RecentEventView, len(rows))
|
||||
for i := range rows {
|
||||
views[i] = rows[i].view(
|
||||
byEvent[rows[i].ID], attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// view projects a loaded row for rendering. deliveries is the
|
||||
// event's deliveries, oldest first, and attempts their recorded
|
||||
// attempts keyed by delivery ID.
|
||||
func (r *recentEventRow) view(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
statusTargetID string,
|
||||
) RecentEventView {
|
||||
v := RecentEventView{
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
Received: humanize.Time(r.CreatedAt),
|
||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||
Size: humanize.Bytes(r.BodyBytes),
|
||||
ProcessingTime: processingTime(deliveries, attempts),
|
||||
}
|
||||
|
||||
if r.ResubmittedFromID != nil {
|
||||
v.ResubmittedFromID = *r.ResubmittedFromID
|
||||
}
|
||||
|
||||
if statusTargetID != "" {
|
||||
v.Status, v.StatusClass = targetStatus(
|
||||
deliveries, attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// processingTime is how long the event's slowest delivery took,
|
||||
// from being queued to its last recorded attempt, time spent
|
||||
// waiting between retries included. A delivery is queued when its
|
||||
// event is received, or when an operator replays it, so a replay
|
||||
// is timed from the replay rather than from the event's arrival.
|
||||
// It is "in progress" while any delivery is pending or retrying,
|
||||
// and empty for an event with no deliveries.
|
||||
func processingTime(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
) string {
|
||||
if len(deliveries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var slowest time.Duration
|
||||
|
||||
for i := range deliveries {
|
||||
if !deliveries[i].Status.Terminal() {
|
||||
return "in progress"
|
||||
}
|
||||
|
||||
tries := attempts[deliveries[i].ID]
|
||||
if len(tries) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
last := tries[len(tries)-1].CreatedAt
|
||||
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
|
||||
}
|
||||
|
||||
return slowest.Round(time.Millisecond).String()
|
||||
}
|
||||
|
||||
// targetStatus is what the target answered for the event, and the
|
||||
// colour to show it in: the HTTP status code of the last attempt of
|
||||
// the event's newest delivery to the target. Without a code it is
|
||||
// "no response" when that attempt failed before a response
|
||||
// arrived, the delivery's status ("pending") before any attempt,
|
||||
// and "not sent" when the event has no delivery to the target.
|
||||
func targetStatus(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
targetID string,
|
||||
) (string, string) {
|
||||
newest := -1
|
||||
|
||||
for i := range deliveries {
|
||||
if deliveries[i].TargetID == targetID {
|
||||
newest = i
|
||||
}
|
||||
}
|
||||
|
||||
if newest < 0 {
|
||||
return "not sent", "text-gray-400"
|
||||
}
|
||||
|
||||
tries := attempts[deliveries[newest].ID]
|
||||
if len(tries) == 0 {
|
||||
return string(deliveries[newest].Status), "text-gray-400"
|
||||
}
|
||||
|
||||
code := tries[len(tries)-1].StatusCode
|
||||
|
||||
switch {
|
||||
case code == 0:
|
||||
return "no response", "text-red-600"
|
||||
case code >= http.StatusInternalServerError:
|
||||
return strconv.Itoa(code), "text-red-600"
|
||||
case code >= http.StatusBadRequest:
|
||||
return strconv.Itoa(code), "text-yellow-600"
|
||||
case code >= http.StatusMultipleChoices:
|
||||
return strconv.Itoa(code), "text-gray-500"
|
||||
case code >= http.StatusOK:
|
||||
return strconv.Itoa(code), "text-green-600"
|
||||
default:
|
||||
return strconv.Itoa(code), "text-gray-500"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// statusTitle marks the status column's cell in a recent events
|
||||
// row; it is absent from the page when the column is not shown.
|
||||
const statusTitle = `title="HTTP status from the HTTP target"`
|
||||
|
||||
// recentEventsFixture is one started app and a webhook whose
|
||||
// recent events list a test fills.
|
||||
type recentEventsFixture struct {
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
webhook *database.Webhook
|
||||
webhookDB *gorm.DB
|
||||
}
|
||||
|
||||
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
|
||||
t.Helper()
|
||||
|
||||
f := &recentEventsFixture{}
|
||||
|
||||
var dbMgr *database.WebhookDBManager
|
||||
|
||||
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
f.webhook = seedWebhook(t, f.db)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
f.webhookDB = webhookDB
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *recentEventsFixture) render(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
||||
}
|
||||
|
||||
// event records an event received at receivedAt.
|
||||
func (f *recentEventsFixture) event(
|
||||
t *testing.T, contentType, body string, receivedAt time.Time,
|
||||
) *database.Event {
|
||||
t.Helper()
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: f.webhook.ID,
|
||||
Method: http.MethodPost,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}
|
||||
event.CreatedAt = receivedAt
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(event).Error)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// delivery records a delivery of the event to the target, queued
|
||||
// when the event was received.
|
||||
func (f *recentEventsFixture) delivery(
|
||||
t *testing.T,
|
||||
event *database.Event,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
return f.deliveryQueuedAt(
|
||||
t, event, targetID, status, event.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// deliveryQueuedAt records a delivery of the event to the target,
|
||||
// queued at queuedAt, as a replay is.
|
||||
func (f *recentEventsFixture) deliveryQueuedAt(
|
||||
t *testing.T,
|
||||
event *database.Event,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
queuedAt time.Time,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
dlv := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targetID,
|
||||
Status: status,
|
||||
}
|
||||
dlv.CreatedAt = queuedAt
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(dlv).Error)
|
||||
|
||||
return dlv
|
||||
}
|
||||
|
||||
// attempt records one attempt of the delivery that finished took
|
||||
// after the delivery was queued, with HTTP status code (0 for no
|
||||
// response).
|
||||
func (f *recentEventsFixture) attempt(
|
||||
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
result := &database.DeliveryResult{
|
||||
DeliveryID: dlv.ID,
|
||||
AttemptNum: 1,
|
||||
StatusCode: code,
|
||||
}
|
||||
result.CreatedAt = dlv.CreatedAt.Add(took)
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(result).Error)
|
||||
}
|
||||
|
||||
// statusCell is the status column's cell as the page renders it.
|
||||
func statusCell(class, text string) string {
|
||||
return `<span class="font-medium ` + class + `" ` + statusTitle +
|
||||
`>` + text + `</span>`
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list
|
||||
// holds the 50 newest events, newest first, and not one more.
|
||||
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
base := time.Now().Add(-time.Hour)
|
||||
|
||||
for i := range 51 {
|
||||
f.event(
|
||||
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
|
||||
base.Add(time.Duration(i)*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
|
||||
assert.NotContains(t, body, "application/x-recent-00")
|
||||
assert.Contains(t, body, "application/x-recent-01")
|
||||
assert.Less(
|
||||
t,
|
||||
strings.Index(body, "application/x-recent-50"),
|
||||
strings.Index(body, "application/x-recent-49"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
|
||||
// time relative with the UTC timestamp on hover, its body size,
|
||||
// and its processing time once every delivery has finished.
|
||||
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||
|
||||
receivedAt := time.Now().Add(-210 * time.Second).
|
||||
UTC().Truncate(time.Second)
|
||||
|
||||
done := f.event(
|
||||
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
|
||||
)
|
||||
f.attempt(
|
||||
t,
|
||||
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
|
||||
0, 1500*time.Millisecond,
|
||||
)
|
||||
|
||||
waiting := f.event(t, "text/plain", "{}", receivedAt)
|
||||
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`<span title="`+receivedAt.Format(time.DateTime)+
|
||||
` UTC">3 minutes ago</span>`,
|
||||
)
|
||||
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
||||
assert.Contains(t, body, ">1.5s</span>")
|
||||
assert.Contains(t, body, ">in progress</span>")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
|
||||
// webhook with exactly one HTTP target shows, colour-coded, what
|
||||
// that target answered for each event. The log target beside it
|
||||
// does not count against "exactly one".
|
||||
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, code := range []int{204, 302, 404, 503, 0} {
|
||||
dlv := f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
)
|
||||
f.attempt(t, dlv, code, time.Second)
|
||||
}
|
||||
|
||||
f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
f.event(t, contentTypeJSON, "{}", now)
|
||||
|
||||
// A replay is a newer delivery, and its answer is the one shown.
|
||||
replayed := f.event(t, contentTypeJSON, "{}", now)
|
||||
f.attempt(t, f.delivery(
|
||||
t, replayed, target.ID, database.DeliveryStatusFailed,
|
||||
), 502, time.Second)
|
||||
f.attempt(t, f.deliveryQueuedAt(
|
||||
t, replayed, target.ID, database.DeliveryStatusDelivered,
|
||||
now.Add(time.Minute),
|
||||
), 200, time.Second)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, statusCell("text-green-600", "204"))
|
||||
assert.Contains(t, body, statusCell("text-gray-500", "302"))
|
||||
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
|
||||
assert.Contains(t, body, statusCell("text-red-600", "503"))
|
||||
assert.Contains(t, body, statusCell("text-red-600", "no response"))
|
||||
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
|
||||
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
|
||||
assert.Contains(t, body, statusCell("text-green-600", "200"))
|
||||
assert.NotContains(t, body, ">502<")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
|
||||
// status column is absent when the webhook has no HTTP target or
|
||||
// more than one.
|
||||
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string][]database.TargetType{
|
||||
"none": {database.TargetTypeLog},
|
||||
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
|
||||
}
|
||||
|
||||
for name, types := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||
|
||||
for _, tt := range types {
|
||||
target := seedTarget(t, f.db, f.webhook.ID, tt)
|
||||
f.attempt(t, f.delivery(
|
||||
t, event, target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
), 200, time.Second)
|
||||
}
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, `title="Body size"`)
|
||||
assert.NotContains(t, body, statusTitle)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -415,16 +415,21 @@ func (h *Handlers) renderSourceDetail(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Find(&targets)
|
||||
|
||||
var events []database.Event
|
||||
var events []RecentEventView
|
||||
|
||||
if h.dbMgr.DBExists(webhook.ID) {
|
||||
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
||||
if dbErr == nil {
|
||||
webhookDB.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Order("created_at DESC").Limit(
|
||||
recentEventLimit,
|
||||
).Find(&events)
|
||||
events, dbErr = h.loadRecentEvents(
|
||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||
)
|
||||
}
|
||||
|
||||
if dbErr != nil {
|
||||
h.log.Error(
|
||||
"failed to load recent events",
|
||||
"webhook_id", webhook.ID, "error", dbErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||
// 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.
|
||||
// Alpine.js is fetched at build time rather than committed, so nothing
|
||||
// in the repo guarantees it is present: this is the check that the page
|
||||
// still gets the JavaScript it asks for.
|
||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,16 +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
|
||||
# extracted file is not committed. script/test, make build and make dev run
|
||||
# this first.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
||||
>static/js/alpine.min.js
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+8
-1
@@ -4,7 +4,9 @@
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||
# only in docker, via script/lint and Dockerfile.lint.
|
||||
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||
# assets the repo does not commit.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -67,6 +69,11 @@ main() {
|
||||
|
||||
go mod download
|
||||
|
||||
# Third-party browser assets are not committed; fetch and verify them
|
||||
# so a fresh clone can build and test.
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
"$ROOT/script/fetch-assets"
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/bin/sh
|
||||
# script/fetch-assets: download the third-party browser assets the web UI
|
||||
# ships and install them under static/. Minified bundles are not committed
|
||||
# (REPO_POLICIES.md: no build artifacts in version control), so the build
|
||||
# fetches them here. Every download is verified against a hardcoded sha256
|
||||
# before it is installed, and any mismatch aborts. Idempotent: an asset
|
||||
# already present with its pinned hash is left alone.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# The sha256 of each installed asset lives in static/vendor.sha256, in
|
||||
# sha256sum(1) format, with paths relative to static/. That file is the
|
||||
# single source of truth: this script verifies against it, and
|
||||
# static/vendor_test.go asserts the bytes embedded into the binary match
|
||||
# it, so the hash cannot rot into a value nothing checks.
|
||||
MANIFEST="static/vendor.sha256"
|
||||
|
||||
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
|
||||
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
|
||||
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
|
||||
# a <script> tag.
|
||||
ALPINE_VERSION="3.14.9"
|
||||
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
|
||||
# sha256 of alpinejs-3.14.9.tgz
|
||||
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
|
||||
ALPINE_MEMBER="package/dist/cdn.min.js"
|
||||
ALPINE_DEST="js/alpine.min.js"
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 "$1" | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
# expected_sha256 <path-relative-to-static>
|
||||
expected_sha256() {
|
||||
awk -v want="$1" '$2 == want { print $1; found = 1 }
|
||||
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
|
||||
}
|
||||
|
||||
# verify <file> <expected-sha256> <what>
|
||||
verify() {
|
||||
actual="$(sha256_of "$1")"
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "fetch-assets: sha256 mismatch for $3" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# up_to_date <path-relative-to-static> <expected-sha256>
|
||||
up_to_date() {
|
||||
[ -f "$ROOT/static/$1" ] || return 1
|
||||
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
|
||||
}
|
||||
|
||||
fetch_alpine() {
|
||||
want="$(expected_sha256 "$ALPINE_DEST")"
|
||||
|
||||
if up_to_date "$ALPINE_DEST" "$want"; then
|
||||
echo "fetch-assets: static/$ALPINE_DEST already at $want"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
|
||||
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
|
||||
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
|
||||
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
|
||||
|
||||
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
|
||||
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
|
||||
rm -rf "$tmp"
|
||||
trap - EXIT INT TERM
|
||||
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
|
||||
}
|
||||
|
||||
# Re-check every manifest entry against what is now on disk, so an entry
|
||||
# no script installs fails loudly instead of passing silently.
|
||||
verify_manifest() {
|
||||
while read -r want path; do
|
||||
case "$want" in '' | '#'*) continue ;; esac
|
||||
if [ ! -f "$ROOT/static/$path" ]; then
|
||||
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
verify "$ROOT/static/$path" "$want" "static/$path"
|
||||
done <"$ROOT/$MANIFEST"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
fetch_alpine
|
||||
verify_manifest
|
||||
echo "fetch-assets: all assets in $MANIFEST verified"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -28,7 +28,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
"$ROOT/script/assets"
|
||||
go test -v -race -timeout 90s ./...
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
||||
@@ -0,0 +1,92 @@
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
const manifestPath = "vendor.sha256"
|
||||
|
||||
// fetchHint is appended to every failure here: the assets the manifest
|
||||
// covers are fetched by the build, not committed, so a fresh clone that
|
||||
// has not run script/fetch-assets fails this test and should be told why.
|
||||
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
|
||||
"the pinned third-party assets"
|
||||
|
||||
// TestVendoredAssetsMatchManifest asserts that every asset listed in
|
||||
// static/vendor.sha256 is embedded in the binary with exactly the pinned
|
||||
// bytes. script/fetch-assets verifies the same hashes at download time;
|
||||
// this test verifies them again on what actually ships, so a build that
|
||||
// skipped, cached, or subverted the fetch cannot produce a binary serving
|
||||
// unpinned third-party JavaScript.
|
||||
func TestVendoredAssetsMatchManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := readManifest(t)
|
||||
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
|
||||
|
||||
for path, want := range entries {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := static.Static.ReadFile(path)
|
||||
require.NoErrorf(
|
||||
t, err,
|
||||
"%s is listed in %s but is not embedded; %s",
|
||||
path, manifestPath, fetchHint,
|
||||
)
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
require.Equalf(
|
||||
t, want, got,
|
||||
"embedded %s does not match its pinned sha256 in %s; %s",
|
||||
path, manifestPath, fetchHint,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
|
||||
// format with paths relative to static/.
|
||||
func readManifest(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Open(manifestPath)
|
||||
require.NoError(t, err, "opening %s", manifestPath)
|
||||
|
||||
defer func() { require.NoError(t, f.Close()) }()
|
||||
|
||||
entries := make(map[string]string)
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
require.Lenf(
|
||||
t, fields, 2,
|
||||
"%s: malformed entry %q, want \"<sha256> <path>\"",
|
||||
manifestPath, line,
|
||||
)
|
||||
|
||||
sum, path := fields[0], fields[1]
|
||||
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
|
||||
entries[path] = sum
|
||||
}
|
||||
|
||||
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
|
||||
|
||||
return entries
|
||||
}
|
||||
@@ -187,12 +187,24 @@
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
||||
{{if .ResubmittedFromID}}
|
||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||
<span title="Body size">{{.Size}}</span>
|
||||
{{if .ProcessingTime}}
|
||||
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
||||
{{end}}
|
||||
{{if .Status}}
|
||||
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
||||
{{end}}
|
||||
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user