Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840c795c66 |
+4
-5
@@ -1,15 +1,14 @@
|
|||||||
# .git is deliberately NOT excluded: the build derives the version it stamps
|
|
||||||
# into the binary from it (script/version). Nor is any tracked file: git in
|
|
||||||
# the build would see it as deleted and mark the version -dirty. Only
|
|
||||||
# untracked files belong here.
|
|
||||||
#
|
|
||||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
||||||
# that keeps the check stages from replaying a cached pass. See the lint
|
# that keeps the check stages from replaying a cached pass. See the lint
|
||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
|
.git/
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# needed. The tarball in 3p/ must stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
.editorconfig
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ jobs:
|
|||||||
run: script/ci-mark-superseded
|
run: script/ci-mark-superseded
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# Every commit that changes more than docs writes a new fingerprint
|
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
||||||
# into the context, which invalidates the `COPY . .` layer of both
|
# commit legitimately replays the whole image from cache and stays
|
||||||
# check stages: a commit that was never linted, formatted-checked,
|
# cheap. Every other commit writes a new fingerprint into the context,
|
||||||
# tested and built cannot report success from cache. Docs-only
|
# which invalidates the `COPY . .` layer of both check stages: a
|
||||||
# commits rebuild too, since the context also carries `.git`.
|
# commit that was never linted, formatted-checked, tested and built
|
||||||
|
# cannot report success from cache.
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
||||||
|
|||||||
+7
-15
@@ -38,8 +38,8 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
|||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
# suite executes. git is what script/version derives the version with.
|
# suite executes.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -55,22 +55,14 @@ COPY . .
|
|||||||
# from its tarball in 3p/.
|
# from its tarball in 3p/.
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary: the VERSION build arg when one is
|
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||||
# given, otherwise what script/version derives from the .git the build
|
# nothing in this stage can derive it: script/docker resolves it on the
|
||||||
# context carries, so any `docker build .` of a clone stamps its commit.
|
# host and passes it in. The default is what a bare `docker build .`
|
||||||
# With neither, as from a source tarball, it is "unknown".
|
# 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
|
# Declared here, below the test step, so a changed version does not
|
||||||
# invalidate its cached layer.
|
# invalidate its cached layer.
|
||||||
ARG VERSION
|
ARG VERSION=unknown
|
||||||
|
|
||||||
# A context that carries .git must not stamp "unknown": that means git is
|
|
||||||
# missing here or refused to read the checkout, and the image could not be
|
|
||||||
# traced back to its commit.
|
|
||||||
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
|
||||||
echo "version is unknown although the build context carries .git" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
# Version stamped into the binary. Derived from git by script/version;
|
# Version stamped into the binary. Derived from git by script/version;
|
||||||
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
||||||
# how the Dockerfile passes its build arg in.
|
# unavailable, which is how the Dockerfile passes its build arg in.
|
||||||
VERSION ?= $(shell script/version)
|
VERSION ?= $(shell script/version)
|
||||||
|
|
||||||
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
||||||
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
|
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
||||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||||
# is required: a plain assignment loses to the command-line definition it
|
# is required: a plain assignment loses to the command-line definition it
|
||||||
|
|||||||
@@ -1065,7 +1065,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`
|
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1123,21 +1123,13 @@ build itself.
|
|||||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||||
| No git metadata | `unknown` |
|
| No git metadata | `unknown` |
|
||||||
|
|
||||||
The image derives it the same way, from the `.git` that the build
|
`unknown` is what a source tarball or a `docker build .` with no
|
||||||
context carries, so any `docker build .` of a clone stamps the commit it
|
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
||||||
was built from; a shallow clone of one branch has no tags and stamps the
|
the build context carries no git metadata and the image cannot derive
|
||||||
short SHA. `.dockerignore` must therefore leave out neither `.git` nor
|
the version itself: `script/docker` (and so `make docker`) resolves it
|
||||||
any tracked file, which git in the build would see as deleted, marking
|
on the host and passes it in as the `VERSION` build arg. A build that
|
||||||
the version `-dirty`. A `VERSION` build arg (`--build-arg VERSION=...`)
|
reports `unknown` is a build nobody told what it was; it is not a
|
||||||
takes precedence; `script/docker` (and so `make docker`) passes the one
|
failure, but it cannot be traced back to a commit.
|
||||||
`script/version` resolves on the host. The image build fails if its
|
|
||||||
context carries `.git` and the version still comes out `unknown`, which
|
|
||||||
means git in the build could not read the checkout.
|
|
||||||
|
|
||||||
`unknown` is what a source tarball, or a `docker build` with no `.git`
|
|
||||||
in its context and no `VERSION` build arg, reports. A build that reports
|
|
||||||
`unknown` is a build nobody told what it was; it is not a failure, but
|
|
||||||
it cannot be traced back to a commit.
|
|
||||||
|
|
||||||
`make version` prints what the current checkout would stamp, and
|
`make version` prints what the current checkout would stamp, and
|
||||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||||
@@ -1389,7 +1381,7 @@ The codebase uses consistent naming throughout (rename completed in
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
webhooker's data model has nine entities organized into two tiers: the
|
webhooker's data model has eleven entities organized into two tiers: the
|
||||||
**application tier** (user and webhook configuration) and the **event
|
**application tier** (user and webhook configuration) and the **event
|
||||||
tier** (event ingestion, delivery, and logging).
|
tier** (event ingestion, delivery, and logging).
|
||||||
|
|
||||||
@@ -1418,6 +1410,13 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ (one row: running counts of events) │
|
||||||
|
│ │ EventTotals │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ ┌──────────────┐ (one row per target: running counts │
|
||||||
|
│ │ TargetTotals │ of its deliveries) │
|
||||||
|
│ └──────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1650,7 +1649,6 @@ data for auditing, for replay, and for resubmission.
|
|||||||
| `headers` | JSON | Complete request headers |
|
| `headers` | JSON | Complete request headers |
|
||||||
| `body` | text | Raw request body |
|
| `body` | text | Raw request body |
|
||||||
| `content_type` | string | Content-Type header value |
|
| `content_type` | string | Content-Type header value |
|
||||||
| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit |
|
|
||||||
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
|
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
||||||
@@ -1671,6 +1669,7 @@ status across potentially multiple attempts.
|
|||||||
| `event_id` | UUID | Foreign key → Event |
|
| `event_id` | UUID | Foreign key → Event |
|
||||||
| `target_id`| UUID | Foreign key → Target |
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
|
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||||
|
|
||||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
DeliveryResults.
|
DeliveryResults.
|
||||||
@@ -1738,33 +1737,65 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
|
#### EventTotals and TargetTotals
|
||||||
|
|
||||||
|
Running counts in each event database, read by the statistics pane at the
|
||||||
|
top of the webhook page. `EventTotals` is one row:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | ------- | ----------- |
|
||||||
|
| `events` | integer | Events ever stored, resubmitted copies included |
|
||||||
|
| `events_removed` | integer | Events retention has deleted |
|
||||||
|
|
||||||
|
`TargetTotals` is one row per target, created by the first delivery to it:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------------- | ------- | ----------- |
|
||||||
|
| `target_id` | UUID | The target (primary key) |
|
||||||
|
| `deliveries` | integer | Deliveries to it ever created, replays included |
|
||||||
|
| `delivered` | integer | Of those, how many became `delivered` |
|
||||||
|
| `failed` | integer | Of those, how many became `failed` |
|
||||||
|
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
||||||
|
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
||||||
|
|
||||||
|
Each count changes in the transaction that writes or deletes the rows it
|
||||||
|
counts. The pane's lifetime events are `events`, and its lifetime
|
||||||
|
deliveries and failures are `deliveries` and `failed` summed over the
|
||||||
|
targets; each figure within retention is the same less what retention
|
||||||
|
removed, so neither needs the rows themselves. Its last-10-minutes and
|
||||||
|
last-24-hours figures are counted from the `events` and `deliveries`
|
||||||
|
indexes over just that window, the deliveries in one query grouped by
|
||||||
|
target. Its failure percentage for a window is the deliveries that became
|
||||||
|
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||||
|
a dash when none did.
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
tags, so `AutoMigrate` creates them on a fresh database:
|
||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events and find the newest |
|
||||||
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||||
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||||
no statistics on these tables, and without them it rates the `deleted_at`
|
rates the `deleted_at` index, which every live row matches, above an index on
|
||||||
index, which every live row matches, above an index on a column matched
|
a column matched against several values or compared with `<`. So every index
|
||||||
against several values or compared with `<`. So every index but the last also
|
but the last also covers `deleted_at`. It comes second, so that retention can
|
||||||
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
use the index without it, except in `events`, where `created_at` is compared
|
||||||
index without it, except in `events`, where `created_at` is compared with `<`
|
with `<` and SQLite narrows by a `<` only on the last column it uses.
|
||||||
and SQLite narrows by a `<` only on the last column it uses.
|
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting` includes these fields from `BaseModel`.
|
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||||
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||||
soft delete:
|
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||||
|
only counts, keyed by a numeric `id` and by `target_id`:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1806,6 +1837,8 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
|
- **EventTotals** and **TargetTotals** — running counts of the above,
|
||||||
|
the deliveries per target, kept through retention
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created (and lazily on first access for webhooks that predate this
|
created (and lazily on first access for webhooks that predate this
|
||||||
@@ -2786,6 +2819,7 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
|
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -3173,9 +3207,8 @@ version is fixed independently of the compiler's:
|
|||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
runs on musl. Both builds go through `make build`, the relink adding
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||||
stamps the version. The version is the `VERSION` build arg if one is
|
stamps the version. The version arrives as the `VERSION` build arg,
|
||||||
given, otherwise derived from the `.git` in the context, and the
|
since the context has no `.git` (see
|
||||||
stage fails if a context with `.git` would stamp `unknown` (see
|
|
||||||
[Version stamping](#version-stamping)).
|
[Version stamping](#version-stamping)).
|
||||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
@@ -3207,17 +3240,16 @@ A layer cache lets `docker build .` exit 0 in seconds with the lint and
|
|||||||
test stages replayed rather than executed, which would make a green
|
test stages replayed rather than executed, which would make a green
|
||||||
check meaningless. The `check` workflow therefore writes
|
check meaningless. The `check` workflow therefore writes
|
||||||
`.ci-fingerprint` into the build context before building. Its value is
|
`.ci-fingerprint` into the build context before building. Its value is
|
||||||
the hash of the last commit that touched anything other than `*.md`,
|
the hash of the last commit that touched the build context, so:
|
||||||
`LICENSE` and `.editorconfig`, so:
|
|
||||||
|
|
||||||
- Any commit that changes code (including a squash merge whose tree
|
- Any commit that changes code (including a squash merge whose tree
|
||||||
matches an already-built branch) gets a new fingerprint, invalidates
|
matches an already-built branch) gets a new fingerprint, invalidates
|
||||||
the `COPY . .` layer of both check stages, and really runs
|
the `COPY . .` layer of both check stages, and really runs
|
||||||
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
||||||
run that reports success ran them.
|
run that reports success ran them.
|
||||||
- A docs-only commit leaves the fingerprint unchanged, but it still
|
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||||
rebuilds in full: the context also carries `.git`, which changes with
|
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
||||||
every commit (see [Version stamping](#version-stamping)).
|
anyway — so the image replays from cache and costs seconds.
|
||||||
|
|
||||||
The module download layer sits above `COPY . .` and stays cached either
|
The module download layer sits above `COPY . .` and stays cached either
|
||||||
way.
|
way.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
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/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
@@ -30,6 +29,7 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
deliveries []database.Delivery
|
deliveries []database.Delivery
|
||||||
results []database.DeliveryResult
|
results []database.DeliveryResult
|
||||||
depths []struct{ Depth int }
|
depths []struct{ Depth int }
|
||||||
|
removed []database.TargetTotals
|
||||||
)
|
)
|
||||||
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||||
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
|
||||||
|
|
||||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||||
// stranded pending deliveries, and the queue depth count.
|
// stranded pending deliveries, and the queue depth count.
|
||||||
@@ -123,25 +123,89 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Order("attempt_num ASC").Find(&results),
|
Order("attempt_num ASC").Find(&results),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||||
|
|
||||||
// Retention's three deletes (reapExpired), whose subqueries are built
|
// Retention (reapExpired, deleteEvents): one batch of expired
|
||||||
// afresh for each statement as it builds them.
|
// events, then their attempts, deliveries and the events.
|
||||||
expiredEventIDs := func() *gorm.DB {
|
var expired []string
|
||||||
return dry.Model(&database.Event{}).Select("id").
|
|
||||||
Where("created_at < ?", cutoff)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
||||||
|
"idx_events_created_at (created_at<?)")
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
||||||
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
Select("id").Where("event_id IN ?", ids),
|
||||||
).Delete(&database.DeliveryResult{}),
|
).Delete(&database.DeliveryResult{}),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
"idx_delivery_results_delivery_id (delivery_id=?)",
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
"idx_deliveries_event_id (event_id=?)")
|
||||||
"event_id IN (?)", expiredEventIDs(),
|
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
||||||
).Delete(&database.Delivery{}),
|
Select("target_id, count(*) AS deliveries_removed, "+
|
||||||
"idx_deliveries_event_id (event_id=?)", byAge)
|
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
database.DeliveryStatusFailed).
|
||||||
"created_at < ?", cutoff,
|
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
||||||
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
"idx_deliveries_event_id (event_id=?)")
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
||||||
|
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
||||||
|
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||||
|
// page's statistics (readEventStats in the handlers): deliveries in
|
||||||
|
// progress, each target's deliveries finished since a time, which must
|
||||||
|
// come from the index alone, events received since a time, and the
|
||||||
|
// newest event, which must come straight off an index rather than from
|
||||||
|
// sorting every event.
|
||||||
|
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(uuid.New().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dry := db.Session(&gorm.Session{DryRun: true})
|
||||||
|
since := time.Now()
|
||||||
|
|
||||||
|
var (
|
||||||
|
count int64
|
||||||
|
newest []time.Time
|
||||||
|
byTarget []struct{ TargetID string }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).Count(&count),
|
||||||
|
"idx_deliveries_status (status=? AND deleted_at=?)")
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Select("target_id, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed).
|
||||||
|
Where("status IN ? AND finished_at >= ?",
|
||||||
|
[]database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
}, since).
|
||||||
|
Group("target_id").Find(&byTarget),
|
||||||
|
"COVERING INDEX idx_deliveries_status "+
|
||||||
|
"(status=? AND deleted_at=? AND finished_at>?)")
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||||
|
Where("created_at >= ?", since).Count(&count),
|
||||||
|
"idx_events_deleted_at_created_at "+
|
||||||
|
"(deleted_at=? AND created_at>?)")
|
||||||
|
|
||||||
|
newestEvent := dry.Model(&database.Event{}).
|
||||||
|
Order("created_at DESC").Limit(1).Pluck("created_at", &newest)
|
||||||
|
assertPlanUses(t, db, newestEvent,
|
||||||
|
"idx_events_deleted_at_created_at (deleted_at=?)")
|
||||||
|
assert.NotContains(t, queryPlan(t, db, newestEvent), "TEMP B-TREE")
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
@@ -152,6 +216,18 @@ func assertPlanUses(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
plan := queryPlan(t, db, built)
|
||||||
|
|
||||||
|
for _, index := range indexes {
|
||||||
|
assert.Contains(t, plan, index, built.Statement.SQL.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryPlan returns SQLite's plan for a statement GORM built in a dry
|
||||||
|
// run, run with the same SQL and arguments GORM would send.
|
||||||
|
func queryPlan(t *testing.T, db, built *gorm.DB) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
var plan []struct{ Detail string }
|
var plan []struct{ Detail string }
|
||||||
|
|
||||||
require.NoError(t, db.Raw(
|
require.NoError(t, db.Raw(
|
||||||
@@ -159,8 +235,5 @@ func assertPlanUses(
|
|||||||
built.Statement.Vars...,
|
built.Statement.Vars...,
|
||||||
).Scan(&plan).Error)
|
).Scan(&plan).Error)
|
||||||
|
|
||||||
for _, index := range indexes {
|
return fmt.Sprint(plan)
|
||||||
assert.Contains(t, fmt.Sprint(plan), index,
|
|
||||||
built.Statement.SQL.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ func NewTestRetentionReaper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportReapBatchSize exposes how many expired events one retention
|
||||||
|
// transaction deletes.
|
||||||
|
const ExportReapBatchSize = reapBatchSize
|
||||||
|
|
||||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||||
r.sweep(ctx)
|
r.sweep(ctx)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
@@ -37,7 +41,7 @@ type Delivery struct {
|
|||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||||
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||||
|
|
||||||
// DeletedAt repeats the BaseModel field only to be the second column
|
// DeletedAt repeats the BaseModel field only to be the second column
|
||||||
@@ -45,6 +49,13 @@ type Delivery struct {
|
|||||||
// gives.
|
// gives.
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
|
// FinishedAt is when the delivery became delivered or failed, and
|
||||||
|
// nil while it is pending or retrying. It and then TargetID end the
|
||||||
|
// status index, so the webhook page counts each target's deliveries
|
||||||
|
// that finished in a recent window by reading just that window from
|
||||||
|
// the index.
|
||||||
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitzero"`
|
Event Event `json:"event,omitzero"`
|
||||||
Target Target `json:"target,omitzero"`
|
Target Target `json:"target,omitzero"`
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ type Event struct {
|
|||||||
Body string `gorm:"type:text" json:"body"`
|
Body string `gorm:"type:text" json:"body"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
|
||||||
// is stored so the recent events list can show it without reading
|
|
||||||
// the body.
|
|
||||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
// an operator resubmit. It is nil for an event that arrived on
|
// an operator resubmit. It is nil for an event that arrived on
|
||||||
// the receiver, which is every event created before the column
|
// the receiver, which is every event created before the column
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The running totals in a webhook's event database keep the webhook
|
||||||
|
// page's lifetime figures right after retention has removed the rows
|
||||||
|
// they count, and let the page show them without counting every row.
|
||||||
|
// Each total changes in the transaction that writes or deletes the
|
||||||
|
// rows it counts.
|
||||||
|
|
||||||
|
// EventTotals is the single row counting a webhook's events: every
|
||||||
|
// event ever stored, and how many of them retention has deleted.
|
||||||
|
type EventTotals struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
|
||||||
|
Events int64 `gorm:"not null"`
|
||||||
|
EventsRemoved int64 `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddEventTotals updates.
|
||||||
|
func (EventTotals) TableName() string {
|
||||||
|
return "event_totals"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetTotals is one row per target counting its deliveries: every
|
||||||
|
// delivery ever created, how many became delivered and how many
|
||||||
|
// failed, and how many deliveries and failed deliveries retention has
|
||||||
|
// deleted. The webhook's delivery figures are these rows summed.
|
||||||
|
type TargetTotals struct {
|
||||||
|
TargetID string `gorm:"type:uuid;primaryKey"`
|
||||||
|
|
||||||
|
Deliveries int64 `gorm:"not null"`
|
||||||
|
Delivered int64 `gorm:"not null"`
|
||||||
|
Failed int64 `gorm:"not null"`
|
||||||
|
|
||||||
|
DeliveriesRemoved int64 `gorm:"not null"`
|
||||||
|
FailedRemoved int64 `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddTargetTotals updates.
|
||||||
|
func (TargetTotals) TableName() string {
|
||||||
|
return "target_totals"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddEventTotals adds each count in add to the webhook's event totals.
|
||||||
|
// Call it on the transaction that writes or deletes the events it
|
||||||
|
// counts.
|
||||||
|
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`UPDATE event_totals SET
|
||||||
|
events = events + ?,
|
||||||
|
events_removed = events_removed + ?`,
|
||||||
|
add.Events, add.EventsRemoved,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding to event totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTargetTotals adds each count in add to the totals of the target
|
||||||
|
// add.TargetID names, creating its row the first time. Call it on the
|
||||||
|
// transaction that writes or deletes the deliveries it counts.
|
||||||
|
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
||||||
|
failed, deliveries_removed, failed_removed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (target_id) DO UPDATE SET
|
||||||
|
deliveries = deliveries + excluded.deliveries,
|
||||||
|
delivered = delivered + excluded.delivered,
|
||||||
|
failed = failed + excluded.failed,
|
||||||
|
deliveries_removed =
|
||||||
|
deliveries_removed + excluded.deliveries_removed,
|
||||||
|
failed_removed = failed_removed + excluded.failed_removed`,
|
||||||
|
add.TargetID, add.Deliveries, add.Delivered,
|
||||||
|
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"adding to totals of target %s: %w", add.TargetID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ package database
|
|||||||
|
|
||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||||
|
// TargetTotals) live in
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ import (
|
|||||||
// computation.
|
// computation.
|
||||||
const hoursPerDay = 24
|
const hoursPerDay = 24
|
||||||
|
|
||||||
|
// reapBatchSize is how many expired events one retention transaction
|
||||||
|
// deletes. A transaction holds the event database's write lock, which
|
||||||
|
// the receiver and the delivery workers wait for, so a large prune is
|
||||||
|
// split into transactions each short enough to finish well inside the
|
||||||
|
// busy timeout.
|
||||||
|
const reapBatchSize = 1000
|
||||||
|
|
||||||
// RetentionReaperParams holds the fx dependencies for the
|
// RetentionReaperParams holds the fx dependencies for the
|
||||||
// RetentionReaper.
|
// RetentionReaper.
|
||||||
type RetentionReaperParams struct {
|
type RetentionReaperParams struct {
|
||||||
@@ -265,57 +272,97 @@ func retentionCutoff(
|
|||||||
), true
|
), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
// reapExpired hard-deletes the events older than cutoff, with their
|
||||||
// results, deliveries, and events associated with events older than
|
// deliveries and delivery results, reapBatchSize events per
|
||||||
// cutoff. Deletes are unscoped so rows are physically removed rather
|
// transaction until none is left. It returns the number of events
|
||||||
// than soft-deleted, reclaiming disk. It returns the number of events
|
|
||||||
// deleted.
|
// deleted.
|
||||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||||
// Fresh subqueries are built per statement to avoid reusing a
|
var total int64
|
||||||
// mutated builder across executions.
|
|
||||||
expiredEventIDs := func() *gorm.DB {
|
|
||||||
return db.Model(&Event{}).
|
|
||||||
Select("id").
|
|
||||||
Where("created_at < ?", cutoff)
|
|
||||||
}
|
|
||||||
expiredDeliveryIDs := func() *gorm.DB {
|
|
||||||
return db.Model(&Delivery{}).
|
|
||||||
Select("id").
|
|
||||||
Where("event_id IN (?)", expiredEventIDs())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Delivery results whose delivery belongs to an expired event.
|
for {
|
||||||
res := db.Unscoped().
|
var eventIDs []string
|
||||||
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
|
||||||
Delete(&DeliveryResult{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"deleting expired delivery results: %w",
|
|
||||||
res.Error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Deliveries belonging to an expired event.
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
del := db.Unscoped().
|
err := tx.Unscoped().Model(&Event{}).
|
||||||
Where("event_id IN (?)", expiredEventIDs()).
|
Where("created_at < ?", cutoff).
|
||||||
Delete(&Delivery{})
|
Limit(reapBatchSize).
|
||||||
if del.Error != nil {
|
Pluck("id", &eventIDs).Error
|
||||||
return 0, fmt.Errorf(
|
if err != nil {
|
||||||
"deleting expired deliveries: %w",
|
return fmt.Errorf("selecting expired events: %w", err)
|
||||||
del.Error,
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. The expired events themselves.
|
if len(eventIDs) == 0 {
|
||||||
ev := db.Unscoped().
|
return nil
|
||||||
Where("created_at < ?", cutoff).
|
}
|
||||||
Delete(&Event{})
|
|
||||||
if ev.Error != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"deleting expired events: %w",
|
|
||||||
ev.Error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ev.RowsAffected, nil
|
return deleteEvents(tx, eventIDs)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total += int64(len(eventIDs))
|
||||||
|
|
||||||
|
if len(eventIDs) < reapBatchSize {
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteEvents hard-deletes the given events and, in foreign-key-safe
|
||||||
|
// order before them, their delivery results and deliveries, then adds
|
||||||
|
// what it deleted to the running totals. It runs on reapExpired's
|
||||||
|
// transaction, so the totals change exactly when the rows do. Deletes
|
||||||
|
// are unscoped so rows are physically removed rather than
|
||||||
|
// soft-deleted, reclaiming disk.
|
||||||
|
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
|
||||||
|
// 1. The delivery results of the events' deliveries.
|
||||||
|
err := tx.Unscoped().
|
||||||
|
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
|
||||||
|
Select("id").
|
||||||
|
Where("event_id IN ?", eventIDs)).
|
||||||
|
Delete(&DeliveryResult{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting expired delivery results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The events' deliveries, after counting them, and the failed
|
||||||
|
// ones among them, per target. The status is tested in the select
|
||||||
|
// list rather than the WHERE clause: there, SQLite would read every
|
||||||
|
// failed delivery the webhook has through the status index,
|
||||||
|
// instead of only these through the event_id index.
|
||||||
|
var removed []TargetTotals
|
||||||
|
|
||||||
|
err = tx.Unscoped().Model(&Delivery{}).
|
||||||
|
Select("target_id, count(*) AS deliveries_removed, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||||
|
DeliveryStatusFailed).
|
||||||
|
Where("event_id IN ?", eventIDs).
|
||||||
|
Group("target_id").
|
||||||
|
Find(&removed).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting expired deliveries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Unscoped().
|
||||||
|
Where("event_id IN ?", eventIDs).
|
||||||
|
Delete(&Delivery{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting expired deliveries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The events themselves.
|
||||||
|
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
|
||||||
|
if ev.Error != nil {
|
||||||
|
return fmt.Errorf("deleting expired events: %w", ev.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range removed {
|
||||||
|
err = AddTargetTotals(tx, removed[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readEventTotals reads a webhook database's row of event totals,
|
||||||
|
// asserting that it has exactly one.
|
||||||
|
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.EventTotals
|
||||||
|
|
||||||
|
require.NoError(t, db.Find(&rows).Error)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTargetTotals reads a webhook database's target totals, keyed by
|
||||||
|
// target.
|
||||||
|
func readTargetTotals(
|
||||||
|
t *testing.T, db *gorm.DB,
|
||||||
|
) map[string]database.TargetTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, db.Find(&rows).Error)
|
||||||
|
|
||||||
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
byTarget[row.TargetID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return byTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
|
||||||
|
// database starts with one row of zero event totals and no target
|
||||||
|
// totals, that adding to a target twice adds to the one row, and that
|
||||||
|
// opening the database again keeps everything added.
|
||||||
|
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fresh := readEventTotals(t, db)
|
||||||
|
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
|
||||||
|
assert.Empty(t, readTargetTotals(t, db))
|
||||||
|
|
||||||
|
first, second := uuid.New().String(), uuid.New().String()
|
||||||
|
|
||||||
|
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||||
|
Events: 2,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: first, Deliveries: 2, Delivered: 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: first, Failed: 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: second, Deliveries: 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Drop the cached connection so the next open reopens the file,
|
||||||
|
// as a restart would.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
db, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
|
||||||
|
readEventTotals(t, db))
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
first: {
|
||||||
|
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
|
||||||
|
},
|
||||||
|
second: {TargetID: second, Deliveries: 1},
|
||||||
|
}, readTargetTotals(t, db))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedExpiredEvents stores count events created at the given time,
|
||||||
|
// each with a delivered delivery to one target and a failed delivery
|
||||||
|
// to the other, and one attempt for each delivery.
|
||||||
|
func seedExpiredEvents(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
count int,
|
||||||
|
createdAt time.Time,
|
||||||
|
delivered, failed string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
events := make([]database.Event, count)
|
||||||
|
deliveries := make([]database.Delivery, 0, 2*count)
|
||||||
|
|
||||||
|
for i := range events {
|
||||||
|
events[i] = database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
}
|
||||||
|
events[i].ID = uuid.New().String()
|
||||||
|
events[i].CreatedAt = createdAt
|
||||||
|
|
||||||
|
deliveries = append(deliveries,
|
||||||
|
database.Delivery{
|
||||||
|
EventID: events[i].ID,
|
||||||
|
TargetID: delivered,
|
||||||
|
Status: database.DeliveryStatusDelivered,
|
||||||
|
},
|
||||||
|
database.Delivery{
|
||||||
|
EventID: events[i].ID,
|
||||||
|
TargetID: failed,
|
||||||
|
Status: database.DeliveryStatusFailed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
|
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
||||||
|
|
||||||
|
results := make([]database.DeliveryResult, len(deliveries))
|
||||||
|
for i := range deliveries {
|
||||||
|
results[i] = database.DeliveryResult{
|
||||||
|
DeliveryID: deliveries[i].ID, AttemptNum: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||||
|
// larger than one transaction's batch removes every expired event with
|
||||||
|
// its deliveries and delivery results, keeps the recent event, and
|
||||||
|
// adds what it removed to the event and target totals, so the totals
|
||||||
|
// within retention match the rows still stored.
|
||||||
|
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expired := database.ExportReapBatchSize + 1
|
||||||
|
delivered, failed := uuid.New().String(), uuid.New().String()
|
||||||
|
seedExpiredEvents(t, db, webhookID, expired,
|
||||||
|
time.Now().Add(-40*24*time.Hour), delivered, failed)
|
||||||
|
|
||||||
|
// One recent event, delivered to the first target.
|
||||||
|
recent := seedEventChain(t, db, webhookID, time.Now())
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", recent.deliveryID).
|
||||||
|
Update("target_id", delivered).Error)
|
||||||
|
|
||||||
|
// The totals storing those rows would have left.
|
||||||
|
n := int64(expired)
|
||||||
|
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||||
|
Events: n + 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: failed, Deliveries: n, Failed: n,
|
||||||
|
}))
|
||||||
|
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
// Only the recent event's rows are left.
|
||||||
|
for _, model := range []any{
|
||||||
|
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
|
||||||
|
} {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(model).Count(&count).Error)
|
||||||
|
assert.Equal(t, int64(1), count, "%T rows left", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertChainPresent(t, db, recent)
|
||||||
|
|
||||||
|
eventTotals := readEventTotals(t, db)
|
||||||
|
assert.Equal(t, database.EventTotals{
|
||||||
|
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
|
||||||
|
}, eventTotals)
|
||||||
|
|
||||||
|
targetTotals := readTargetTotals(t, db)
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
delivered: {
|
||||||
|
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
||||||
|
DeliveriesRemoved: n,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
TargetID: failed, Deliveries: n, Failed: n,
|
||||||
|
DeliveriesRemoved: n, FailedRemoved: n,
|
||||||
|
},
|
||||||
|
}, targetTotals)
|
||||||
|
|
||||||
|
// A sweep with nothing left to remove changes nothing.
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, eventTotals, readEventTotals(t, db))
|
||||||
|
assert.Equal(t, targetTotals, readTargetTotals(t, db))
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, and DeliveryResults.
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
|
// running totals of them (EventTotals, TargetTotals).
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -295,6 +296,7 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
|
&EventTotals{}, &TargetTotals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
@@ -305,6 +307,18 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new database gets its row of event totals, all zero. Target
|
||||||
|
// totals rows are created by the first delivery to each target.
|
||||||
|
err = db.FirstOrCreate(&EventTotals{}).Error
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"creating event totals for webhook database %s: %w",
|
||||||
|
webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
"opened per-webhook database",
|
"opened per-webhook database",
|
||||||
"webhook_id", webhookID,
|
"webhook_id", webhookID,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// targetTotals reads one target's totals from a webhook database, all
|
||||||
|
// zero when it has no row.
|
||||||
|
func targetTotals(
|
||||||
|
t *testing.T, db *gorm.DB, targetID string,
|
||||||
|
) database.TargetTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, db.Where("target_id = ?", targetID).
|
||||||
|
Find(&rows).Error)
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return database.TargetTotals{TargetID: targetID}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
||||||
|
// write records for the webhook page's statistics: the time a delivery
|
||||||
|
// finished, set only when it becomes delivered or failed, and one more
|
||||||
|
// on its target's delivered or failed total.
|
||||||
|
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
status database.DeliveryStatus
|
||||||
|
finished bool
|
||||||
|
delivered int64
|
||||||
|
failed int64
|
||||||
|
}{
|
||||||
|
{database.DeliveryStatusRetrying, false, 0, 0},
|
||||||
|
{database.DeliveryStatusDelivered, true, 1, 0},
|
||||||
|
{database.DeliveryStatusFailed, true, 0, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.status), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, tt.status,
|
||||||
|
))
|
||||||
|
|
||||||
|
var stored database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
||||||
|
assert.Equal(t, tt.status, stored.Status)
|
||||||
|
|
||||||
|
if tt.finished {
|
||||||
|
require.NotNil(t, stored.FinishedAt)
|
||||||
|
assert.False(t, stored.FinishedAt.Before(before))
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, stored.FinishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, database.TargetTotals{
|
||||||
|
TargetID: targetID,
|
||||||
|
Delivered: tt.delivered,
|
||||||
|
Failed: tt.failed,
|
||||||
|
}, targetTotals(t, db, targetID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
|
||||||
|
// delivery retention deleted while the engine still held it. Failing
|
||||||
|
// it afterwards writes no row, so it adds no failure either: retention
|
||||||
|
// has already counted what it removed.
|
||||||
|
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, db.Unscoped().
|
||||||
|
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, database.DeliveryStatusFailed,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Equal(t, database.TargetTotals{TargetID: targetID},
|
||||||
|
targetTotals(t, db, targetID))
|
||||||
|
}
|
||||||
@@ -1554,8 +1554,9 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
status database.DeliveryStatus,
|
status database.DeliveryStatus,
|
||||||
) error {
|
) error {
|
||||||
err := webhookDB.Model(d).
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
Update("status", status).Error
|
return writeDeliveryStatus(tx, d, status)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"updating delivery %s to status %s: %w",
|
"updating delivery %s to status %s: %w",
|
||||||
@@ -1574,6 +1575,36 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
||||||
|
// becomes delivered or failed also gets the time it finished, and is
|
||||||
|
// added to its target's delivered or failed total. It is counted only
|
||||||
|
// if the row was still there to update: retention may have deleted it
|
||||||
|
// while the engine was working on it.
|
||||||
|
func writeDeliveryStatus(
|
||||||
|
tx *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) error {
|
||||||
|
if !status.Terminal() {
|
||||||
|
return tx.Model(d).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
res := tx.Model(d).Updates(map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"finished_at": time.Now(),
|
||||||
|
})
|
||||||
|
if res.Error != nil || res.RowsAffected == 0 {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||||
|
if status == database.DeliveryStatusFailed {
|
||||||
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTargetTotals(tx, add)
|
||||||
|
}
|
||||||
|
|
||||||
// settleStatus moves a delivery to its outcome status and reports a
|
// settleStatus moves a delivery to its outcome status and reports a
|
||||||
// failed write through bookkeepingFailed, which leaves the row
|
// failed write through bookkeepingFailed, which leaves the row
|
||||||
// recoverable. It exists so the target call sites read as one
|
// recoverable. It exists so the target call sites read as one
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
&database.Event{},
|
&database.Event{},
|
||||||
&database.Delivery{},
|
&database.Delivery{},
|
||||||
&database.DeliveryResult{},
|
&database.DeliveryResult{},
|
||||||
|
&database.EventTotals{},
|
||||||
|
&database.TargetTotals{},
|
||||||
))
|
))
|
||||||
|
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ func (e *Engine) ExportDeliverSlack(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
||||||
|
// target type, so no metric moves.
|
||||||
|
func (e *Engine) ExportUpdateDeliveryStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) error {
|
||||||
|
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportProcessNewTask exposes processNewTask.
|
// ExportProcessNewTask exposes processNewTask.
|
||||||
func (e *Engine) ExportProcessNewTask(
|
func (e *Engine) ExportProcessNewTask(
|
||||||
ctx context.Context, task *Task,
|
ctx context.Context, task *Task,
|
||||||
|
|||||||
@@ -299,8 +299,9 @@ func countInFlightDeliveries(
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReplayDelivery writes the new pending delivery row and returns
|
// createReplayDelivery writes the new pending delivery row, adds it to
|
||||||
// the task that carries it to the delivery engine.
|
// its target's totals in the same transaction, and returns the task
|
||||||
|
// that carries it to the delivery engine.
|
||||||
//
|
//
|
||||||
// The row is written with associations omitted, and neither Event nor
|
// The row is written with associations omitted, and neither Event nor
|
||||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||||
@@ -319,7 +320,16 @@ func createReplayDelivery(
|
|||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Omit(clause.Associations).Create(dlv).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTargetTotals(tx, database.TargetTotals{
|
||||||
|
TargetID: dlv.TargetID, Deliveries: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.Task{}, err
|
return delivery.Task{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ func assertEventCopy(
|
|||||||
assert.Equal(t, original.Method, fresh.Method)
|
assert.Equal(t, original.Method, fresh.Method)
|
||||||
assert.Equal(t, original.Headers, fresh.Headers)
|
assert.Equal(t, original.Headers, fresh.Headers)
|
||||||
assert.Equal(t, original.Body, fresh.Body)
|
assert.Equal(t, original.Body, fresh.Body)
|
||||||
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
|
|
||||||
assert.Equal(t, original.ContentType, fresh.ContentType)
|
assert.Equal(t, original.ContentType, fresh.ContentType)
|
||||||
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
||||||
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,6 +71,29 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookStatsForTest returns the figures the statistics pane on a
|
||||||
|
// webhook's page shows, from the webhook's entrypoints and targets
|
||||||
|
// loaded as that page loads them.
|
||||||
|
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
||||||
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
|
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
||||||
|
|
||||||
|
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
||||||
|
// handlers_test package.
|
||||||
|
func FinishedByTargetForTest(
|
||||||
|
webhookDB *gorm.DB, since time.Time,
|
||||||
|
) ([]TargetFinished, error) {
|
||||||
|
return finishedByTarget(webhookDB, since)
|
||||||
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||||
maxBodyShift = 20
|
maxBodyShift = 20
|
||||||
// recentEventLimit is the number of recent events to show.
|
// recentEventLimit is the number of recent events to show.
|
||||||
recentEventLimit = 50
|
recentEventLimit = 20
|
||||||
// paginationPerPage is the number of items per page.
|
// paginationPerPage is the number of items per page.
|
||||||
paginationPerPage = 25
|
paginationPerPage = 25
|
||||||
|
|
||||||
@@ -91,18 +91,22 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, and navbar templates. The page file must be
|
// base, htmlheader, and navbar templates, and with any further files
|
||||||
// listed first so that its root action ({{template "base" .}})
|
// the page includes. The page file must be listed first so that its
|
||||||
// becomes the template set's entry point.
|
// root action ({{template "base" .}}) becomes the template set's entry
|
||||||
func parsePageTemplate(pageFile string) *template.Template {
|
// point.
|
||||||
|
func parsePageTemplate(
|
||||||
|
pageFile string, included ...string,
|
||||||
|
) *template.Template {
|
||||||
|
files := append([]string{
|
||||||
|
pageFile,
|
||||||
|
"base.html",
|
||||||
|
"htmlheader.html",
|
||||||
|
"navbar.html",
|
||||||
|
}, included...)
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
template.ParseFS(
|
template.ParseFS(templates.Templates, files...),
|
||||||
templates.Templates,
|
|
||||||
pageFile,
|
|
||||||
"base.html",
|
|
||||||
"htmlheader.html",
|
|
||||||
"navbar.html",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ func New(
|
|||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"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
|
|
||||||
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
|
||||||
// and reads its size from body_bytes, recorded when the event was
|
|
||||||
// stored.
|
|
||||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
|
||||||
"resubmitted_from_id, body_bytes"
|
|
||||||
|
|
||||||
// recentAttemptColumns is the part of a recorded attempt the list
|
|
||||||
// uses. The event log's deliveryResultColumns also reads response
|
|
||||||
// bodies, which the list does not show.
|
|
||||||
const recentAttemptColumns = "delivery_id, status_code, created_at"
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
|
||||||
// when the attempt's result was recorded, which is when the attempt
|
|
||||||
// finished.
|
|
||||||
type recentAttemptRow struct {
|
|
||||||
DeliveryID string
|
|
||||||
StatusCode int
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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 := loadRecentAttempts(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadRecentAttempts loads the recorded attempts of the listed
|
|
||||||
// events' deliveries, keyed by delivery ID, each delivery's in
|
|
||||||
// attempt order. The IDs go in chunks for the reason
|
|
||||||
// deliveryIDChunkSize gives.
|
|
||||||
func loadRecentAttempts(
|
|
||||||
webhookDB *gorm.DB, deliveryIDs []string,
|
|
||||||
) (map[string][]recentAttemptRow, error) {
|
|
||||||
byDelivery := make(map[string][]recentAttemptRow)
|
|
||||||
|
|
||||||
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
|
|
||||||
var rows []recentAttemptRow
|
|
||||||
|
|
||||||
err := webhookDB.Model(&database.DeliveryResult{}).
|
|
||||||
Select(recentAttemptColumns).
|
|
||||||
Where("delivery_id IN ?", chunk).
|
|
||||||
Order("attempt_num ASC").
|
|
||||||
Find(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range rows {
|
|
||||||
id := rows[i].DeliveryID
|
|
||||||
byDelivery[id] = append(byDelivery[id], rows[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return byDelivery, 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][]recentAttemptRow,
|
|
||||||
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][]recentAttemptRow,
|
|
||||||
) 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][]recentAttemptRow,
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"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, with its body's
|
|
||||||
// size as the receiver records it.
|
|
||||||
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,
|
|
||||||
BodyBytes: int64(len(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 is
|
|
||||||
// headed "50 Most Recent Events" and 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.Contains(t, body, ">50 Most Recent Events</h2>")
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
|
||||||
// body's size in bytes, not characters, with the event it stores.
|
|
||||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
seedEntrypoint(t, f.db, f.webhook.ID)
|
|
||||||
|
|
||||||
// Two bytes per character.
|
|
||||||
body := strings.Repeat("é", 1024)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost, "/webhook/x",
|
|
||||||
strings.NewReader(body),
|
|
||||||
)
|
|
||||||
|
|
||||||
rctx := chi.NewRouteContext()
|
|
||||||
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
|
|
||||||
|
|
||||||
req = req.WithContext(context.WithValue(
|
|
||||||
req.Context(), chi.RouteCtxKey, rctx,
|
|
||||||
))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
f.h.HandleWebhook().ServeHTTP(w, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var stored database.Event
|
|
||||||
|
|
||||||
require.NoError(t, f.webhookDB.First(&stored).Error)
|
|
||||||
assert.Equal(t, int64(2048), stored.BodyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
|
|
||||||
// list cannot be loaded the page answers with an error, rather than
|
|
||||||
// an empty list claiming the webhook has no events.
|
|
||||||
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
|
||||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
||||||
|
|
||||||
f.attempt(t, f.delivery(
|
|
||||||
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
), 200, time.Second)
|
|
||||||
|
|
||||||
// The attempts are the list's last query, so its events and
|
|
||||||
// deliveries have already loaded when it fails.
|
|
||||||
require.NoError(t, f.webhookDB.Exec(
|
|
||||||
"DROP TABLE delivery_results",
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.NotContains(t, w.Body.String(), "No events received yet.")
|
|
||||||
}
|
|
||||||
@@ -62,23 +62,6 @@ func renderSourceDetailPage(
|
|||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
w := serveSourceDetailPage(t, h, sess, webhookID)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveSourceDetailPage runs the real source detail handler for a
|
|
||||||
// webhook and returns its response, whatever its status.
|
|
||||||
func serveSourceDetailPage(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
webhookID string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
@@ -104,7 +87,9 @@ func serveSourceDetailPage(
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.HandleSourceDetail().ServeHTTP(w, req)
|
h.HandleSourceDetail().ServeHTTP(w, req)
|
||||||
|
|
||||||
return w
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
||||||
|
|||||||
@@ -415,23 +415,16 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"webhook_id = ?", webhook.ID,
|
"webhook_id = ?", webhook.ID,
|
||||||
).Find(&targets)
|
).Find(&targets)
|
||||||
|
|
||||||
var events []RecentEventView
|
var events []database.Event
|
||||||
|
|
||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if dbErr == nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
webhookDB.Where(
|
||||||
|
"webhook_id = ?", webhook.ID,
|
||||||
return
|
).Order("created_at DESC").Limit(
|
||||||
}
|
recentEventLimit,
|
||||||
|
).Find(&events)
|
||||||
events, err = loadRecentEvents(
|
|
||||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.serverError(w, "failed to load recent events", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +450,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"Targets": delivery.NewTargetViews(targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ func (s eventSource) event() *database.Event {
|
|||||||
Method: s.Method,
|
Method: s.Method,
|
||||||
Headers: s.HeadersJSON,
|
Headers: s.HeadersJSON,
|
||||||
Body: string(s.Body),
|
Body: string(s.Body),
|
||||||
BodyBytes: int64(len(s.Body)),
|
|
||||||
ContentType: s.ContentType,
|
ContentType: s.ContentType,
|
||||||
ResubmittedFromID: s.ResubmittedFromID,
|
ResubmittedFromID: s.ResubmittedFromID,
|
||||||
}
|
}
|
||||||
@@ -253,11 +252,12 @@ func requestEventSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target
|
// createAndFanOut writes the event and one pending delivery per target,
|
||||||
// in a single transaction, then hands the tasks to the delivery
|
// and adds them to the webhook's running totals, in a single
|
||||||
// engine. It is the only path by which an event and its deliveries are
|
// transaction, then hands the tasks to the delivery engine. It is the
|
||||||
// created, so a resubmitted event is retried, SSRF-guarded and
|
// only path by which an event and its deliveries are created, so a
|
||||||
// circuit-broken exactly as a received one is.
|
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
||||||
|
// exactly as a received one is.
|
||||||
//
|
//
|
||||||
// The tasks are returned as well as queued, so a caller can report how
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
// many targets the event went to.
|
// many targets the event went to.
|
||||||
@@ -297,6 +297,13 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = database.AddEventTotals(tx, database.EventTotals{Events: 1})
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
@@ -355,8 +362,9 @@ func (h *Handlers) finishWebhookResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliveryTasks creates one pending delivery per target in the
|
// buildDeliveryTasks creates one pending delivery per target in the
|
||||||
// transaction and returns the tasks for the delivery engine. The
|
// transaction, adds each to its target's totals, and returns the tasks
|
||||||
// caller owns the transaction and rolls it back on error.
|
// for the delivery engine. The caller owns the transaction and rolls
|
||||||
|
// it back on error.
|
||||||
func buildDeliveryTasks(
|
func buildDeliveryTasks(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
@@ -380,6 +388,13 @@ func buildDeliveryTasks(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = database.AddTargetTotals(tx, database.TargetTotals{
|
||||||
|
TargetID: targets[i].ID, Deliveries: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
tasks = append(tasks, delivery.Task{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The spans of the two recent windows the statistics pane reports on:
|
||||||
|
// the last 10 minutes and the last 24 hours.
|
||||||
|
const (
|
||||||
|
shortWindow = 10 * time.Minute
|
||||||
|
longWindow = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// percent turns a fraction into a percentage.
|
||||||
|
const percent = 100
|
||||||
|
|
||||||
|
// WebhookStats holds the figures in the statistics pane at the top of
|
||||||
|
// the webhook page.
|
||||||
|
type WebhookStats struct {
|
||||||
|
Entrypoints int
|
||||||
|
ActiveEntrypoints int
|
||||||
|
Targets int
|
||||||
|
ActiveTargets int
|
||||||
|
|
||||||
|
// Lifetime counts every event, delivery and failure the webhook
|
||||||
|
// has had, and WithinRetention those still stored.
|
||||||
|
Lifetime Counts
|
||||||
|
WithinRetention Counts
|
||||||
|
|
||||||
|
// InProgress counts the deliveries still pending or retrying.
|
||||||
|
InProgress int64
|
||||||
|
|
||||||
|
// LastEventAt is when the newest stored event arrived, or nil when
|
||||||
|
// none is stored.
|
||||||
|
LastEventAt *time.Time
|
||||||
|
|
||||||
|
Last10Minutes RecentWindow
|
||||||
|
Last24Hours RecentWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts holds a number of events, of deliveries and of failed
|
||||||
|
// deliveries.
|
||||||
|
type Counts struct {
|
||||||
|
Events int64
|
||||||
|
Deliveries int64
|
||||||
|
Failures int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentWindow holds what happened in one recent window: the events
|
||||||
|
// received in it, and the deliveries that became delivered or failed in
|
||||||
|
// it.
|
||||||
|
type RecentWindow struct {
|
||||||
|
Events int64
|
||||||
|
Delivered int64
|
||||||
|
Failed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetFinished is how many of one target's deliveries became
|
||||||
|
// delivered, and how many failed, in a recent window.
|
||||||
|
type TargetFinished struct {
|
||||||
|
TargetID string
|
||||||
|
Delivered int64
|
||||||
|
Failed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailurePercent is the share of the deliveries finished in the window
|
||||||
|
// that failed, or a dash when none finished. Deliveries still pending
|
||||||
|
// or retrying are not counted either way.
|
||||||
|
func (w RecentWindow) FailurePercent() string {
|
||||||
|
finished := w.Delivered + w.Failed
|
||||||
|
if finished == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
||||||
|
// webhook's entrypoints and targets, as the page has already loaded
|
||||||
|
// them, and from its event database. It returns nil, and logs why, when
|
||||||
|
// the event database cannot be read.
|
||||||
|
func (h *Handlers) loadWebhookStats(
|
||||||
|
webhookID string,
|
||||||
|
entrypoints []database.Entrypoint,
|
||||||
|
targets []database.Target,
|
||||||
|
) *WebhookStats {
|
||||||
|
stats := &WebhookStats{
|
||||||
|
Entrypoints: len(entrypoints),
|
||||||
|
Targets: len(targets),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range entrypoints {
|
||||||
|
if entrypoints[i].Active {
|
||||||
|
stats.ActiveEntrypoints++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
if targets[i].Active {
|
||||||
|
stats.ActiveTargets++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening an event database that does not exist would create it,
|
||||||
|
// and it would hold nothing to count.
|
||||||
|
if !h.dbMgr.DBExists(webhookID) {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||||
|
if err == nil {
|
||||||
|
err = readEventStats(webhookDB, time.Now(), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to read webhook statistics",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEventStats fills in the figures that come from the webhook's
|
||||||
|
// event database. None of them reads every stored row: the totals are
|
||||||
|
// one row for the events and one per target for the deliveries, and
|
||||||
|
// every other figure is read from an index, over only the rows it
|
||||||
|
// counts.
|
||||||
|
func readEventStats(
|
||||||
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
||||||
|
) error {
|
||||||
|
err := readTotals(db, stats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Model(&database.Delivery{}).
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).
|
||||||
|
Count(&stats.InProgress).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting deliveries in progress: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newest []time.Time
|
||||||
|
|
||||||
|
err = db.Model(&database.Event{}).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(1).
|
||||||
|
Pluck("created_at", &newest).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading newest event time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newest) > 0 {
|
||||||
|
stats.LastEventAt = &newest[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Last10Minutes, err = readRecentWindow(
|
||||||
|
db, now.Add(-shortWindow),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Last24Hours, err = readRecentWindow(
|
||||||
|
db, now.Add(-longWindow),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTotals fills in the lifetime and within-retention figures from
|
||||||
|
// the running totals: the events' row, and the targets' rows summed.
|
||||||
|
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
||||||
|
var events database.EventTotals
|
||||||
|
|
||||||
|
err := db.Take(&events).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading event totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []database.TargetTotals
|
||||||
|
|
||||||
|
err = db.Find(&targets).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading target totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Lifetime.Events = events.Events
|
||||||
|
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
stats.Lifetime.Deliveries += t.Deliveries
|
||||||
|
stats.Lifetime.Failures += t.Failed
|
||||||
|
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
||||||
|
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRecentWindow counts the events received, and the deliveries that
|
||||||
|
// became delivered or failed, since the given time.
|
||||||
|
func readRecentWindow(
|
||||||
|
db *gorm.DB, since time.Time,
|
||||||
|
) (RecentWindow, error) {
|
||||||
|
var w RecentWindow
|
||||||
|
|
||||||
|
err := db.Model(&database.Event{}).
|
||||||
|
Where("created_at >= ?", since).
|
||||||
|
Count(&w.Events).Error
|
||||||
|
if err != nil {
|
||||||
|
return w, fmt.Errorf("counting recent events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byTarget, err := finishedByTarget(db, since)
|
||||||
|
if err != nil {
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range byTarget {
|
||||||
|
w.Delivered += f.Delivered
|
||||||
|
w.Failed += f.Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishedByTarget counts, for each target, the deliveries that became
|
||||||
|
// delivered and those that failed since the given time, in one query
|
||||||
|
// over just that window of the deliveries' status index. A target with
|
||||||
|
// neither is left out.
|
||||||
|
func finishedByTarget(
|
||||||
|
db *gorm.DB, since time.Time,
|
||||||
|
) ([]TargetFinished, error) {
|
||||||
|
var byTarget []TargetFinished
|
||||||
|
|
||||||
|
err := db.Model(&database.Delivery{}).
|
||||||
|
Select("target_id, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed).
|
||||||
|
Where("status IN ? AND finished_at >= ?",
|
||||||
|
[]database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
}, since).
|
||||||
|
Group("target_id").
|
||||||
|
Find(&byTarget).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"counting deliveries finished by target: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return byTarget, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
||||||
|
func statsEntrypoint(
|
||||||
|
t *testing.T, db *database.Database, webhookID string, active bool,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
||||||
|
|
||||||
|
return ep.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsDelivery returns an event's delivery to a target.
|
||||||
|
func statsDelivery(
|
||||||
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
||||||
|
) database.Delivery {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var d database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Where(
|
||||||
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
||||||
|
).First(&d).Error)
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsFinish settles a delivery as the delivery engine does: its
|
||||||
|
// final status and the time it finished, and one more on its target's
|
||||||
|
// delivered or failed total, in one transaction.
|
||||||
|
func statsFinish(
|
||||||
|
t *testing.T,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||||
|
if status == database.DeliveryStatusFailed {
|
||||||
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", d.ID).
|
||||||
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTargetTotals(tx, add)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsAge moves an event's arrival back to the given time.
|
||||||
|
func statsAge(
|
||||||
|
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Model(&database.Event{}).
|
||||||
|
Where("id = ?", eventID).
|
||||||
|
Update("created_at", at).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsTargetTotals reads a webhook database's target totals, keyed by
|
||||||
|
// target.
|
||||||
|
func statsTargetTotals(
|
||||||
|
t *testing.T, webhookDB *gorm.DB,
|
||||||
|
) map[string]database.TargetTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Find(&rows).Error)
|
||||||
|
|
||||||
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
byTarget[row.TargetID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return byTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsHistory is the webhook seedStatsHistory builds: its event
|
||||||
|
// database, its newest event, and its two active targets.
|
||||||
|
type statsHistory struct {
|
||||||
|
webhook *database.Webhook
|
||||||
|
webhookDB *gorm.DB
|
||||||
|
newest database.Event
|
||||||
|
first, second string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStatsHistory builds the webhook the statistics test checks: one
|
||||||
|
// day of retention, two entrypoints (one inactive) and three targets
|
||||||
|
// (one inactive). Three events arrive through the receiver, and so
|
||||||
|
// each has a delivery to the two active targets. The oldest event is
|
||||||
|
// past retention, the middle one six hours old, the newest just in.
|
||||||
|
// Their deliveries are settled as the delivery engine would, and a
|
||||||
|
// replay adds a pending delivery to the oldest event.
|
||||||
|
func seedStatsHistory(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
) statsHistory {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "stats", RetentionDays: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
statsEntrypoint(t, db, wh.ID, false)
|
||||||
|
|
||||||
|
first := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
require.NoError(t, db.DB().Model(inactive).
|
||||||
|
Update("active", false).Error)
|
||||||
|
|
||||||
|
router := receiverRouter(h)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, 3)
|
||||||
|
|
||||||
|
oldest, middle, newest := events[0], events[1], events[2]
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, oldest.ID, now.Add(-50*time.Hour))
|
||||||
|
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
||||||
|
|
||||||
|
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
||||||
|
statsFinish(t, webhookDB, oldestFailure,
|
||||||
|
database.DeliveryStatusFailed, now.Add(-49*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, oldest.ID, second.ID),
|
||||||
|
database.DeliveryStatusDelivered, now.Add(-49*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
||||||
|
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther,
|
||||||
|
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
||||||
|
|
||||||
|
return statsHistory{
|
||||||
|
webhook: wh,
|
||||||
|
webhookDB: webhookDB,
|
||||||
|
newest: newest,
|
||||||
|
first: first.ID,
|
||||||
|
second: second.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsPrune runs the real retention reaper until it has removed one
|
||||||
|
// event from the webhook's database, then stops it.
|
||||||
|
func statsPrune(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
log *logger.Logger,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
||||||
|
Config: &config.Config{
|
||||||
|
RetentionSweepInterval: 10 * time.Millisecond,
|
||||||
|
},
|
||||||
|
Database: db,
|
||||||
|
DBManager: dbMgr,
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
var totals database.EventTotals
|
||||||
|
|
||||||
|
err := webhookDB.Take(&totals).Error
|
||||||
|
|
||||||
|
return err == nil && totals.EventsRemoved == 1
|
||||||
|
}, 10*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
lc.RequireStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsPane returns the statistics pane from a rendered webhook page:
|
||||||
|
// everything from its heading to the next heading on the page.
|
||||||
|
func statsPane(t *testing.T, page string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
||||||
|
require.True(t, found, "the page has no statistics pane")
|
||||||
|
|
||||||
|
pane, _, _ = strings.Cut(pane, "<h2")
|
||||||
|
|
||||||
|
return pane
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
||||||
|
// each target's totals and its deliveries finished in the last 24
|
||||||
|
// hours. The first target has three deliveries and the replay, the
|
||||||
|
// second three; the inactive target has none and so no row.
|
||||||
|
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
first, second := hist.first, hist.second
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
first: {TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2},
|
||||||
|
second: {
|
||||||
|
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
||||||
|
},
|
||||||
|
}, statsTargetTotals(t, hist.webhookDB))
|
||||||
|
|
||||||
|
lastDay, err := handlers.FinishedByTargetForTest(
|
||||||
|
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, []handlers.TargetFinished{
|
||||||
|
{TargetID: first, Delivered: 1, Failed: 1},
|
||||||
|
{TargetID: second, Failed: 1},
|
||||||
|
}, lastDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
||||||
|
// the statistics pane shows for the history seedStatsHistory builds,
|
||||||
|
// and each target's totals and recent figures, before and after the
|
||||||
|
// real retention reaper removes the oldest event.
|
||||||
|
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
||||||
|
first, second := hist.first, hist.second
|
||||||
|
|
||||||
|
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
||||||
|
require.NotNil(t, stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, stats.Entrypoints)
|
||||||
|
assert.Equal(t, 1, stats.ActiveEntrypoints)
|
||||||
|
assert.Equal(t, 3, stats.Targets)
|
||||||
|
assert.Equal(t, 2, stats.ActiveTargets)
|
||||||
|
assert.Equal(t, handlers.Counts{Events: 3, Deliveries: 7, Failures: 3},
|
||||||
|
stats.Lifetime)
|
||||||
|
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
||||||
|
assert.Equal(t, int64(2), stats.InProgress)
|
||||||
|
require.NotNil(t, stats.LastEventAt)
|
||||||
|
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
||||||
|
assert.Equal(t, handlers.RecentWindow{
|
||||||
|
Events: 1, Delivered: 1, Failed: 1,
|
||||||
|
}, stats.Last10Minutes)
|
||||||
|
assert.Equal(t, handlers.RecentWindow{
|
||||||
|
Events: 2, Delivered: 1, Failed: 2,
|
||||||
|
}, stats.Last24Hours)
|
||||||
|
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
||||||
|
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
|
||||||
|
|
||||||
|
assertStatsTargets(t, hist)
|
||||||
|
|
||||||
|
// Retention removes the oldest event with its three deliveries:
|
||||||
|
// the first target's failed one and the pending replay, and the
|
||||||
|
// second target's delivered one.
|
||||||
|
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
||||||
|
|
||||||
|
after := h.WebhookStatsForTest(hist.webhook.ID)
|
||||||
|
require.NotNil(t, after)
|
||||||
|
|
||||||
|
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
||||||
|
assert.Equal(t, handlers.Counts{Events: 2, Deliveries: 4, Failures: 2},
|
||||||
|
after.WithinRetention)
|
||||||
|
assert.Equal(t, int64(1), after.InProgress)
|
||||||
|
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
||||||
|
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
||||||
|
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
first: {
|
||||||
|
TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2,
|
||||||
|
DeliveriesRemoved: 2, FailedRemoved: 1,
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
||||||
|
DeliveriesRemoved: 1,
|
||||||
|
},
|
||||||
|
}, statsTargetTotals(t, hist.webhookDB))
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||||
|
assert.Contains(t, pane, "Within retention")
|
||||||
|
assert.Contains(t, pane, "50.0%")
|
||||||
|
assert.Contains(t, pane, "66.7%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
||||||
|
// pane itself, not only the line at the foot of the page, shows the
|
||||||
|
// webhook's retention period, for a finite one and for forever.
|
||||||
|
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
retentionDays int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{30, "30 days"},
|
||||||
|
{database.RetentionForeverDays, "forever"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID,
|
||||||
|
Name: "retention",
|
||||||
|
RetentionDays: tt.retentionDays,
|
||||||
|
}
|
||||||
|
require.NoError(t,
|
||||||
|
db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, pane, "Retention", tt.want)
|
||||||
|
assert.Contains(t, pane, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
||||||
|
// database has never been opened: every count is zero, the
|
||||||
|
// percentages are a dash, and showing the page does not create the
|
||||||
|
// database.
|
||||||
|
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
||||||
|
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
||||||
|
|
||||||
|
statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.False(t, dbMgr.DBExists(wh.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecentWindow_FailurePercent pins the percentage: failed
|
||||||
|
// deliveries out of all that finished in the window.
|
||||||
|
func TestRecentWindow_FailurePercent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
window handlers.RecentWindow
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{handlers.RecentWindow{}, "—"},
|
||||||
|
{handlers.RecentWindow{Events: 4}, "—"},
|
||||||
|
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
||||||
|
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
||||||
|
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
|
|||||||
require.Equal(t, unknown, runScript(t, inner, nil))
|
require.Equal(t, unknown, runScript(t, inner, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// An explicit VERSION, such as the Dockerfile's build arg, wins over
|
// The Docker build has no git metadata, so the version arrives as an
|
||||||
// anything derivable.
|
// environment override. It wins over anything derivable.
|
||||||
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty VERSION is treated as unset rather than stamping an empty
|
// An empty VERSION is treated as unset rather than stamping an empty
|
||||||
// string: a caller exporting VERSION= must not produce a binary
|
// string: the Dockerfile's build arg has a non-empty default, but a
|
||||||
// reporting "".
|
// caller exporting VERSION= must not produce a binary reporting "".
|
||||||
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A caller can define VERSION as the empty string -- `make build
|
// A caller can define VERSION as the empty string -- `make build
|
||||||
// VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no
|
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
|
||||||
// VERSION build arg was given. script/version's own guard does not cover
|
// build VERSION="$VERSION"`. script/version's own guard does not cover
|
||||||
// that: the value never passes through the script. Stamping "" would
|
// that: the value never passes through the script. Stamping "" would
|
||||||
// leave the binary reporting no version and the footer on "dev", which
|
// leave the binary reporting no version and the footer on "dev", which
|
||||||
// is the defect this package exists for.
|
// is the defect this package exists for.
|
||||||
@@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
|||||||
|
|
||||||
require.NotContains(t, dockerfile, "go build",
|
require.NotContains(t, dockerfile, "go build",
|
||||||
"a raw go build bypasses the Makefile's -X flag")
|
"a raw go build bypasses the Makefile's -X flag")
|
||||||
require.Contains(t, dockerfile, "ARG VERSION")
|
require.Contains(t, dockerfile, "ARG VERSION=")
|
||||||
require.Contains(t, dockerfile,
|
require.Contains(t, dockerfile,
|
||||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# The tag comes from script/projectname.
|
# The tag comes from script/projectname.
|
||||||
#
|
#
|
||||||
# The version script/version resolves here goes in as the VERSION build
|
# .dockerignore excludes .git/, so the builder stage cannot derive the
|
||||||
# arg, which takes precedence over what the build would derive from the
|
# version itself. It is resolved here, where the checkout is, and passed
|
||||||
# .git in its context.
|
# in as a build arg; without it the image would stamp itself "unknown".
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+7
-5
@@ -7,16 +7,18 @@
|
|||||||
#
|
#
|
||||||
# Order of precedence:
|
# Order of precedence:
|
||||||
#
|
#
|
||||||
# 1. $VERSION, if set and non-empty: an explicit value, such as the
|
# 1. $VERSION, if set and non-empty. This is how the value reaches a
|
||||||
# Dockerfile's VERSION build arg.
|
# build that cannot derive it: .dockerignore excludes .git/, so the
|
||||||
|
# builder stage has no git metadata and the Dockerfile takes the
|
||||||
|
# value as a build arg instead.
|
||||||
# 2. `git describe --tags --always --dirty` against this checkout. At
|
# 2. `git describe --tags --always --dirty` against this checkout. At
|
||||||
# a clean tagged commit that is exactly the tag; otherwise it
|
# a clean tagged commit that is exactly the tag; otherwise it
|
||||||
# carries the short SHA, the commit distance when a tag is
|
# carries the short SHA, the commit distance when a tag is
|
||||||
# reachable, and a -dirty suffix for uncommitted changes.
|
# reachable, and a -dirty suffix for uncommitted changes.
|
||||||
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
||||||
# source tarball, or a `docker build` with no .git in its context
|
# source tarball, or `docker build .` with no --build-arg. That
|
||||||
# and no VERSION build arg. That case must not fail the build and
|
# case must not fail the build and must not name a tag the tree may
|
||||||
# must not name a tag the tree may not be at, so it names nothing.
|
# not be at, so it names nothing.
|
||||||
#
|
#
|
||||||
# The git step insists the enclosing repository is this checkout, not
|
# The git step insists the enclosing repository is this checkout, not
|
||||||
# merely some repository above it: an unpacked tarball sitting inside an
|
# merely some repository above it: an unpacked tarball sitting inside an
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{template "webhook_stats" .}}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -181,30 +183,18 @@
|
|||||||
<!-- Recent Events -->
|
<!-- Recent Events -->
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
<span class="text-sm text-gray-500">{{.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>
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{{define "webhook_stats"}}
|
||||||
|
<!-- Statistics pane at the top of the webhook page. -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
||||||
|
</div>
|
||||||
|
{{with .Stats}}
|
||||||
|
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Entrypoints</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
||||||
|
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Targets</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
||||||
|
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Deliveries in progress</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Last event</span>
|
||||||
|
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Retention</span>
|
||||||
|
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
||||||
|
<table class="w-full text-center text-gray-900">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
<th></th>
|
||||||
|
<th class="py-2 font-medium">Lifetime</th>
|
||||||
|
<th class="py-2 font-medium">Within retention</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Events</td>
|
||||||
|
<td class="py-2">{{.Lifetime.Events}}</td>
|
||||||
|
<td class="py-2">{{.WithinRetention.Events}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Deliveries</td>
|
||||||
|
<td class="py-2">{{.Lifetime.Deliveries}}</td>
|
||||||
|
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||||
|
<td class="py-2">{{.Lifetime.Failures}}</td>
|
||||||
|
<td class="py-2">{{.WithinRetention.Failures}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div>
|
||||||
|
<table class="w-full text-center text-gray-900">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
<th></th>
|
||||||
|
<th class="py-2 font-medium">Last 10 minutes</th>
|
||||||
|
<th class="py-2 font-medium">Last 24 hours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Events</td>
|
||||||
|
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
||||||
|
<td class="py-2">{{.Last24Hours.Events}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||||
|
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
||||||
|
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
||||||
|
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
||||||
|
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user