Store a target's settings as columns (closes #543)
check / check (push) Waiting to run

A target's settings move from the per-type JSON string in
targets.config to columns: url, headers, timeout_seconds,
forward_query, archive_expiry and archive_rotation. A column a type
does not use stays empty. headers holds the header map as JSON text
through GORM's JSON serializer, and archive_expiry holds a
time.Duration, 0 meaning never. The forms check each setting once,
with the same refusals and messages, and the edit form is filled from
the columns, so the per-type config structs, their parsers and the
"could not be read" branches are gone. A delivery task carries the
six settings in place of the JSON string; tests check that each copy
into a task, and back out of one, keeps all six.

Model: opus-5-5
This commit is contained in:
2026-10-15 20:40:25 +00:00
parent ded39aa158
commit aa12ba19ca
71 changed files with 1665 additions and 2168 deletions
+50 -50
View File
@@ -1579,15 +1579,20 @@ did not arrive on the URL and counts in neither.
A delivery destination for events. Each target defines where and how events
should be forwarded.
| Field | Type | Description |
| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) |
| `config` | JSON text | Type-specific configuration |
| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
| Field | Type | Description |
| ------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) |
| `url` | string | Where an `http` or `slack` target sends each event. It is a credential: the target list shows only its scheme and host, and only the target edit form shows it whole |
| `headers` | JSON text | An `http` target's extra request headers, a JSON object of name to value |
| `timeout_seconds` | integer | An `http` target's per-request timeout; 0 uses the shared 30-second client timeout |
| `forward_query` | boolean | Whether an `http` target is sent the query string each event arrived with |
| `archive_expiry` | integer | How long a `database` target keeps each archived event, as a Go `time.Duration` (nanoseconds); 0 keeps them forever |
| `archive_rotation` | string | How often a `database` target starts a new archive file: `none`, `monthly`, `daily` or `hourly` |
| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
**Relations:** Belongs to Webhook. Has many Deliveries.
@@ -1598,10 +1603,10 @@ should be forwarded.
failed delivery with exponential backoff, up to that many attempts in all,
behind a per-target circuit breaker.
- **`slack`** — Post the event as a formatted message to a Slack-compatible
incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
core as `http` and honours `max_attempts` identically, circuit breaker
included. See the Slack target section under "Per-Webhook Event Databases" for
the message format.
incoming webhook URL, its `url`. It is built on the same HTTP core as `http`
and honours `max_attempts` identically, circuit breaker included. See the
Slack target section under "Per-Webhook Event Databases" for the message
format.
- **`database`** — Archive the full event as a row into the target's own archive
database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for
long-term retention, with an optional creation-validated expiry (default: keep
@@ -1617,29 +1622,28 @@ should be forwarded.
- **`log`** — Write the event to the application log (stdout). Useful for
debugging.
The `config` field stores type-specific configuration as JSON (e.g., destination
URL, custom headers, timeout settings).
Each type uses only its own settings, and the ones it does not use stay empty:
`http` uses `url`, `headers`, `timeout_seconds` and `forward_query`; `slack`
uses `url`; `database` uses `archive_expiry` and `archive_rotation`; `log` uses
none. The add target form, the new webhook form and the target edit form check
every setting before it is saved, and each setting is a column of its own, so
nothing that reads a target parses its settings or has to handle one it cannot
read.
**`http` target configuration:**
An `http` target POSTs the event to its `url`. Its `headers` are applied last,
so they win over the event's own forwarded headers.
| Key | Type | Description |
| -------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `url` | string | Destination the event is POSTed to |
| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers |
| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout |
| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not |
`forwardQuery` is off by default, and the target URL is then sent exactly as
`forward_query` is off by default, and the target URL is then sent exactly as
configured. On, each delivery appends the event's query string to the target
URL, joined with `&` when the URL already has a query string of its own; a
replayed delivery and a resubmitted event's deliveries do the same. Both target
forms offer it as "Pass the query string on to this target", and the target list
shows it when it is on.
`timeout` is capped at **300 seconds**, and the form rejects anything above it
rather than substituting the cap. A delivery attempt holds one of the bounded
pool's workers for its whole duration, so an unbounded timeout would let a
single unresponsive destination stall the queue.
`timeout_seconds` is capped at **300 seconds**, and the form rejects anything
above it rather than substituting the cap. A delivery attempt holds one of the
bounded pool's workers for its whole duration, so an unbounded timeout would let
a single unresponsive destination stall the queue.
`headers` rejects the names the delivery path or `net/http` writes regardless of
what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`,
@@ -1713,7 +1717,7 @@ The event log and the event's own page show the query string with the rest of
the request. The event log leaves out one larger than 32 KiB, as it does request
headers, and links to the event's page, which shows it whole. The `database` and
`log` targets carry it with the rest of the event. An `http` target receives it
only when its `forwardQuery` setting is on.
only when its `forward_query` setting is on.
#### Delivery
@@ -1992,11 +1996,10 @@ target UUID keeps the file name unique. A webhook named `Orders (EU)` with a
target named `Long-term archive` archives into
`archive-orders-eu-long-term-archive-{target_uuid}.db`.
An optional `rotation` in the target's config JSON (e.g. `{"rotation":"daily"}`)
is `none`, the default, which keeps the one file, or `monthly`, `daily` or
`hourly`. A target that rotates writes each event to a file named for the period
of the event's receive time, in UTC, put before the `.db`:
`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly,
A target's `archive_rotation` is `none`, the default, which keeps the one file,
or `monthly`, `daily` or `hourly`. A target that rotates writes each event to a
file named for the period of the event's receive time, in UTC, put before the
`.db`: `archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly,
`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly
its period's events, and the first event of a new period starts the next file,
so a finished period's file can be moved away like any archive. A changed
@@ -2031,12 +2034,11 @@ archiving without stopping the service; a moved or removed archive file is
recreated automatically on the next write, from the SQL files in
`internal/db/migrations/archive/`. A zero-length archive file is written to as a
new archive: SQLite opens it as an empty database, so it holds nothing to lose.
An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is
validated when the target is created — the default (unset or the literal
`never`) keeps rows forever — and rows older than the expiry are pruned each
time the archive is (re)opened. An archive write failure is never silent
success: the delivery records a failed attempt with the error and is marked
failed.
A target's `archive_expiry` is checked by the form that saves it — the default,
`never` on the forms and 0 in the column, keeps rows forever — and rows older
than the expiry are pruned each time the archive is (re)opened. An archive write
failure is never silent success: the delivery records a failed attempt with the
error and is marked failed.
Because reopens only happen on writes, an archive whose target has stopped
receiving events would never be pruned. A background **archive sweeper** closes
@@ -2047,8 +2049,7 @@ traffic. The sweep never creates an archive — a target whose archive file does
not yet exist is skipped, not initialised — it takes the same per-target lock
the write path uses, so it can never interleave with a write, and it leaves the
archive closed afterwards so the move-the-file-away workflow keeps working.
Archives with no expiry, or the expiry `never`, are not touched by the sweep at
all.
Archives with the expiry `never` are not touched by the sweep at all.
For a target with several files, the write path prunes only the file it writes
to, and the sweep prunes every one of them, taking the target's lock for one
@@ -2116,9 +2117,8 @@ compatible services). Each message includes event metadata (HTTP method, content
type, timestamp, body size) and the payload pretty-printed in a code block. JSON
payloads are automatically formatted with indentation for readability; non-JSON
payloads are shown as raw text. Large payloads are truncated to keep messages
reasonable. Config stores `webhookUrl` — the Slack/Mattermost incoming webhook
endpoint. That is the JSON key; the error text for a missing one reads
`webhook_url is required`, which is the message, not the key.
reasonable. The target's `url` is the Slack/Mattermost incoming webhook
endpoint.
The database uses the
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver.
@@ -2156,7 +2156,7 @@ External Service
3. Capture full request as Event
4. Create Delivery records for each active Target
5. Build self-contained delivery.Task structs
(target config + event data inline for
(target settings + event data inline for
bodies < 16 KiB)
6. Notify Engine via channel (no DB read needed)
│
@@ -3060,14 +3060,14 @@ webhooker/
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
│ │ ├── target_database.go # Database archive target, writing through internal/archive
│ │ ├── target_log.go # Log target (stdout)
│ │ ├── target_config_view.go # Masked target config for templates
│ │ ├── target_config_view.go # Masked target settings for templates
│ │ ├── queue_depth.go # Periodic sampler behind the queue-depth gauges
│ │ ├── url_mask.go # Strips credentials from *url.Error
│ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport)
│ ├── handlers/
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
│ │ ├── auth.go # Login, logout handlers
│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target config
│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target settings
│ │ ├── event_resubmit.go # Event resubmit: new event, all currently active targets
│ │ ├── entrypoint_view.go # Masked entrypoint view for templates
│ │ ├── event_log_view.go # Event log projection, byte-capped in SQL
@@ -3082,7 +3082,7 @@ webhooker/
│ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal
│ │ ├── event_log.go # Event log page: loaders, filters, delivery views
│ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle
│ │ ├── target_create.go # Target create, per-type config builders
│ │ ├── target_create.go # Target create, each type's settings from the form
│ │ ├── target_delete.go # Target delete
│ │ ├── target_toggle.go # Target toggle
│ │ ├── shared.go # Helpers shared by several handlers
@@ -3164,7 +3164,7 @@ The server starts via
which triggers the fx lifecycle hooks in dependency order. The
`delivery.Notifier` interface allows the webhook handler to send self-contained
`delivery.Task` slices to the engine without a direct package dependency. Each
task carries all target config and event data inline (for bodies under 16 KiB,
task carries all target settings and event data inline (for bodies under 16 KiB,
`delivery.MaxInlineBodySize`), so the engine can deliver without reading from
any database — it only writes to record results.