Rotate a database target's archive monthly, daily or hourly (closes #379)
check / check (push) Successful in 3m42s

A database target's rotation (none, monthly, daily or hourly) puts the
UTC period of each event's receive time in its archive file name, so
each file holds exactly its period's events. It is on the new webhook
page, the add target form and the target edit form, and shown in the
target list.

Renames move every one of a target's files, the sweep prunes every file
and deletes a rotated file it leaves empty, Download exports every file
oldest first with each row's period, and the target list names the
current file and totals the size of all of them.

Model: opus-5-5
This commit is contained in:
2026-10-02 23:51:42 +00:00
parent 22fa502638
commit 13f881007a
33 changed files with 1753 additions and 328 deletions
+9 -9
View File
@@ -10,10 +10,10 @@ const (
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
)
// archiveExpiryChoice is one entry of a database target's archive
// expiry select: the expiry stored, the label shown, and whether the
// select starts on it.
type archiveExpiryChoice struct {
// archiveChoice is one entry of a database target's archive expiry
// or archive rotation select: the value stored, the label shown, and
// whether the select starts on it.
type archiveChoice struct {
Value string
Label string
Selected bool
@@ -21,8 +21,8 @@ type archiveExpiryChoice struct {
// archiveExpiryChoices lists the archive expiries offered by the new
// webhook page, the add target form and the target edit form.
func archiveExpiryChoices() []archiveExpiryChoice {
return []archiveExpiryChoice{
func archiveExpiryChoices() []archiveChoice {
return []archiveChoice{
{Value: archiveExpiryNever, Label: archiveExpiryNever},
{Value: "1h", Label: "1h"},
{Value: "12h", Label: "12h"},
@@ -37,7 +37,7 @@ func archiveExpiryChoices() []archiveExpiryChoice {
// empty expiry selects never. An expiry that is not one of the
// choices comes first as its own selected entry, so saving the form
// unchanged keeps it.
func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
func archiveExpiryOptions(expiry string) []archiveChoice {
if expiry == "" {
expiry = archiveExpiryNever
}
@@ -52,7 +52,7 @@ func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
}
}
own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true}
own := archiveChoice{Value: expiry, Label: expiry, Selected: true}
return append([]archiveExpiryChoice{own}, options...)
return append([]archiveChoice{own}, options...)
}
+22 -3
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -49,21 +50,39 @@ func expiryShown(
}
// expirySelected returns the target edit page and the expiries its
// select starts on.
// expiry select starts on.
func expirySelected(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) (string, []string) {
t.Helper()
page := targetEditPage(t, env, webhookID, targetID)
return page, selectedIn(page, "expiry")
}
// targetEditPage returns a target's edit page.
func targetEditPage(
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
) string {
t.Helper()
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
return w.Body.String()
}
return page, matched(`<option value="([^"]*)" selected>`, page)
// selectedIn returns the values the select named name on page starts
// on.
func selectedIn(page, name string) []string {
_, rest, _ := strings.Cut(page, `<select id="`+name+`" name="`+name+`"`)
options, _, _ := strings.Cut(rest, "</select>")
return matched(`<option value="([^"]*)" selected>`, options)
}
// TestArchiveExpiryChoices adds a database target with each archive
+42
View File
@@ -0,0 +1,42 @@
package handlers
const (
// archiveRotationNone is the archive rotation that keeps a
// database target's archive in one file. A stored empty rotation
// means the same.
archiveRotationNone = "none"
// tmplKeyArchiveRotationChoices is the template data key for the
// entries of a page's archive rotation select.
tmplKeyArchiveRotationChoices = "ArchiveRotationChoices"
)
// archiveRotationChoices lists the archive rotations offered by the
// new webhook page, the add target form and the target edit form.
func archiveRotationChoices() []archiveChoice {
return []archiveChoice{
{Value: archiveRotationNone, Label: archiveRotationNone},
{Value: "monthly", Label: "monthly"},
{Value: "daily", Label: "daily"},
{Value: "hourly", Label: "hourly"},
}
}
// archiveRotationOptions returns the choices with rotation selected;
// an empty rotation, or one that is not a choice, selects none. A
// stored rotation is always a choice: the forms refuse any other.
func archiveRotationOptions(rotation string) []archiveChoice {
options := archiveRotationChoices()
for i := range options {
if options[i].Value == rotation {
options[i].Selected = true
return options
}
}
options[0].Selected = true
return options
}
+229
View File
@@ -0,0 +1,229 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// rotationNone is the archive rotation that keeps one file.
const rotationNone = "none"
// rotationShown returns the archive rotations the webhook page's
// target list shows.
func rotationShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
return matched(
`Archive Rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID),
)
}
// TestArchiveRotationChoices adds a database target with each archive
// rotation the forms offer, and checks that it is stored as chosen,
// shown in the target list, and that the target edit form starts on
// it. It then edits the target to hourly, keeping its expiry.
func TestArchiveRotationChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
t.Run(rotation, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", "720h")
form.Set("rotation", rotation)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID))
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
form = url.Values{}
form.Set("name", "archive")
form.Set("expiry", "720h")
form.Set("rotation", "hourly")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
`{"expiry":"720h","rotation":"hourly"}`,
storedTarget(t, env, targets[0].ID).Config,
)
})
}
}
// TestArchiveRotationEditStartsOnNone checks the edit form of a
// database target with no rotation stored starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
page := targetEditPage(t, env, webhook.ID, target.ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
}
// TestArchiveRotationRefused proves a rotation that is not one of the
// four is refused on the add target form and the target edit form, and
// that nothing is stored.
func TestArchiveRotationRefused(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("rotation", "weekly")
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
form.Del("type")
w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.JSONEq(t,
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
)
}
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
// page's archive rotation is stored on the archive target it creates.
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
form := url.Values{}
form.Set("name", "rotated")
form.Set("archive", "on")
form.Set("archive_expiry", "720h")
form.Set("archive_rotation", "daily")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
var webhook database.Webhook
require.NoError(t, env.db.DB().
Where("name = ?", "rotated").First(&webhook).Error)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
}
// TestArchiveFileView_Rotated describes a daily target's archive files
// at two times. On a day that has a file, the view names that file;
// on the next, before any event, it names the file the next event
// will go to, not created yet. Both times the size is of every file
// together and the last write the latest of them.
func TestArchiveFileView_Rotated(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
path := delivery.ArchivePath(env.dbMgr, &webhook, target)
stem := strings.TrimSuffix(path, ".db")
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
for i, day := range []string{"2026-10-01", "2026-10-02"} {
file := stem + "-" + day + ".db"
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
view := env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
assert.Empty(t, view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
view = env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
assert.Equal(t, "not created yet", view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive Size: 2.0 kB in 2 files")
}
// renderedPage returns the webhook page.
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
+11 -2
View File
@@ -167,7 +167,16 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func BuildDatabaseTargetConfigForTest(
expiry string,
expiry, rotation string,
) (string, string, error) {
return buildDatabaseTargetConfig(expiry)
return buildDatabaseTargetConfig(expiry, rotation)
}
// ArchiveFileViewForTest exposes archiveFileView, which describes a
// database target's archive files as the target list shows them at
// now.
func (s *Handlers) ArchiveFileViewForTest(
webhook *database.Webhook, target *database.Target, now time.Time,
) *ArchiveFileView {
return s.archiveFileView(webhook, target, now)
}
+36 -5
View File
@@ -436,23 +436,37 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel()
// Empty expiry: the keep-forever default, empty config.
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
// Empty expiry and rotation: the keep-forever, one-file default,
// empty config.
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Empty(t, cfg)
// Explicit never is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
// A rotation is stored as config, with or without an expiry.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"rotation":"daily"}`, cfg)
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest(
"720h", "hourly",
)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg)
}
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
@@ -461,7 +475,7 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
t.Parallel()
for _, bad := range []string{"nonsense", "7d", "-5h"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
require.NoError(t, err)
assert.Contains(
@@ -471,3 +485,20 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
assert.Empty(t, cfg)
}
}
func TestBuildDatabaseTargetConfig_RejectsBadRotation(
t *testing.T,
) {
t.Parallel()
for _, bad := range []string{"weekly", "Daily", " none"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad)
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive rotation",
"rotation %q should be refused", bad,
)
assert.Empty(t, cfg)
}
}
@@ -120,7 +120,7 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
// retention, each with archive on. Nothing is created, and the form
// comes back with the reason and every value entered: name,
// description, retention, URL, the checked archive box and the pruning
// choice.
// and rotation choices.
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
t *testing.T,
) {
@@ -152,6 +152,7 @@ func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
form.Set("http_url", tc.httpURL)
form.Set("archive", "on")
form.Set("archive_expiry", "2160h")
form.Set("archive_rotation", "hourly")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusBadRequest, w.Code)
@@ -166,6 +167,7 @@ func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
assert.Contains(t, page, `name="archive" value="on" checked`)
assert.Contains(t, page, `x-data="collapsible" data-open`)
assert.Contains(t, page, `<option value="2160h" selected>`)
assert.Contains(t, page, `<option value="hourly" selected>`)
assertNothingCreated(t, env.db)
})
+54 -25
View File
@@ -296,9 +296,10 @@ type sourceFormInput struct {
// destination.
HTTPURL string
// Archive asks for a database (archive) target, whose rows expire
// after ArchiveExpiry.
Archive bool
ArchiveExpiry string
// after ArchiveExpiry and whose files rotate by ArchiveRotation.
Archive bool
ArchiveExpiry string
ArchiveRotation string
}
// newSourceFormData builds the template data for the webhook creation
@@ -315,6 +316,9 @@ func newSourceFormData(
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
in.ArchiveRotation,
),
}
}
@@ -341,12 +345,13 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
}
in := sourceFormInput{
Name: r.PostFormValue("name"),
Description: r.PostFormValue("description"),
RetentionDays: r.PostFormValue("retention_days"),
HTTPURL: r.PostFormValue("http_url"),
Archive: r.PostFormValue("archive") != "",
ArchiveExpiry: r.PostFormValue("archive_expiry"),
Name: r.PostFormValue("name"),
Description: r.PostFormValue("description"),
RetentionDays: r.PostFormValue("retention_days"),
HTTPURL: r.PostFormValue("http_url"),
Archive: r.PostFormValue("archive") != "",
ArchiveExpiry: r.PostFormValue("archive_expiry"),
ArchiveRotation: r.PostFormValue("archive_rotation"),
}
refuse := func(errMsg string) {
@@ -417,9 +422,10 @@ func (h *Handlers) newWebhookTargets(
if in.Archive {
requested = append(requested, targetFormInput{
Name: "Archive",
Type: database.TargetTypeDatabase,
Expiry: in.ArchiveExpiry,
Name: "Archive",
Type: database.TargetTypeDatabase,
Expiry: in.ArchiveExpiry,
Rotation: in.ArchiveRotation,
})
}
@@ -626,9 +632,10 @@ func (h *Handlers) renderSourceDetail(
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
tmplKeyTargetForm: targetForm,
"TargetError": targetErr,
// The add target form's select starts on its expiry
// through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
// The add target form's selects start on its expiry and
// rotation through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
tmplKeyArchiveRotationChoices: archiveRotationChoices(),
}
status := http.StatusOK
@@ -1789,6 +1796,8 @@ type targetFormInput struct {
MaxRetries string
// Expiry is a database (archive) target's row expiry.
Expiry string
// Rotation is a database (archive) target's rotation.
Rotation string
}
// targetFormInputFrom reads a target form from a request body. The
@@ -1812,6 +1821,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
}
}
@@ -1832,7 +1842,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase:
return buildDatabaseTargetConfig(in.Expiry)
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
case database.TargetTypeLog:
return "", "", nil
default:
@@ -1953,22 +1963,41 @@ func marshalTargetConfig(cfg any) (string, error) {
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry is validated here, at
// creation time, so an unparseable value is refused instead of
// failing every subsequent delivery. An empty expiry yields an
// empty config (the keep-forever default).
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
// (archive) target. The optional expiry and rotation are validated
// here, at creation time, so a bad value is refused instead of
// failing every subsequent delivery. Each is stored only when set,
// and with neither the config is empty (the keep-forever, one-file
// default).
func buildDatabaseTargetConfig(
expiry, rotation string,
) (string, string, error) {
expiry = strings.TrimSpace(expiry)
if expiry == "" {
return "", "", nil
}
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
}
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
err = delivery.ValidateArchiveRotation(rotation)
if err != nil {
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
}
cfg := map[string]any{}
if expiry != "" {
cfg["expiry"] = expiry
}
if rotation != "" {
cfg["rotation"] = rotation
}
if len(cfg) == 0 {
return "", "", nil
}
configJSON, err := marshalTargetConfig(cfg)
return configJSON, "", err
}
+4 -3
View File
@@ -86,9 +86,10 @@ func (d downloadWriter) Write(b []byte) (int, error) {
// written the response.
//
// It holds renameMu, which every archive rename runs under, while it
// reads the stored names and opens the file, so the file it opens is
// the one those names give. It lets go before the export is streamed:
// once the file is open, a rename does not affect the export.
// reads the stored names and opens the files, so the files it opens
// are the ones those names give. It lets go before the export is
// streamed: once the files are open, a rename does not affect the
// export.
func (h *Handlers) openTargetArchive(
ctx context.Context,
w http.ResponseWriter,
+4
View File
@@ -83,6 +83,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
Timeout: cfg.Timeout,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
Rotation: cfg.Rotation,
}
h.renderTargetEdit(
@@ -242,6 +243,9 @@ func (h *Handlers) renderTargetEdit(
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyError: errMsg,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
form.Rotation,
),
}
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
+5 -4
View File
@@ -591,7 +591,8 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
"Invalid max retries",
},
{
database.TargetTypeDatabase, "name=edited&expiry=7d",
database.TargetTypeDatabase,
"name=edited&expiry=7d&rotation=daily",
"Invalid archive expiry",
},
{database.TargetTypeLog, "name=", "Name is required"},
@@ -613,15 +614,15 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+tc.reason)
// headers is the form's one textarea and expiry its one
// select; every other field is an input.
// headers is the form's one textarea, and expiry and
// rotation its selects; every other field is an input.
for field := range form {
shown := `name="` + field + `" value="` + form.Get(field) + `"`
switch field {
case "headers":
shown = ">" + form.Get(field) + "</textarea>"
case "expiry":
case "expiry", "rotation":
shown = `<option value="` + form.Get(field) + `" selected>`
}
+59 -28
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
@@ -21,8 +22,8 @@ type TargetRowView struct {
// and is nil when the webhook's event database could not be read.
Deliveries *TargetDeliveries
// Archive is a database target's archive file, and nil for a target
// of any other type.
// Archive is a database target's archive files, and nil for a
// target of any other type.
Archive *ArchiveFileView
}
@@ -39,17 +40,23 @@ type TargetDeliveries struct {
}
// ArchiveFileView is what a database target's row shows about its
// archive file.
// archive files.
type ArchiveFileView struct {
// Name is the name of the file an event received now goes to.
Name string
// Note stands in for the size and the last write when there are
// none to show, and is empty when there are.
// Note says that file does not exist yet, or that the files could
// not be read, and is empty otherwise.
Note string
// Size is the size on disk. Written is how long ago the file was
// last written, and WrittenUTC the full time the page shows on
// hover.
// Files counts the target's archive files, and is 0 when there are
// none, or they could not be read, and so no size or last write to
// show.
Files int
// Size is the size on disk of all the files together. Written is
// how long ago the latest of them was last written, and WrittenUTC
// the full time the page shows on hover.
Size string
Written string
WrittenUTC string
@@ -62,6 +69,7 @@ func (h *Handlers) targetRows(
) []TargetRowView {
views := delivery.NewTargetViews(targets)
rows := make([]TargetRowView, len(views))
now := time.Now()
deliveries, err := h.loadTargetDeliveries(webhook.ID)
if err != nil {
@@ -82,7 +90,7 @@ func (h *Handlers) targetRows(
}
if targets[i].Type == database.TargetTypeDatabase {
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
rows[i].Archive = h.archiveFileView(webhook, &targets[i], now)
}
}
@@ -147,34 +155,57 @@ func readTargetDeliveries(
return byTarget, nil
}
// archiveFileView describes a database target's archive file from the
// file's metadata alone; the archive is never opened. The file is found
// by the name the archive writer uses, so it follows a rename of the
// webhook or the target.
// archiveFileView describes a database target's archive files from
// their metadata alone; the archive is never opened. It names the file
// an event received at now goes to. The files are found by the name
// the archive writer uses, so they follow a rename of the webhook or
// the target.
func (h *Handlers) archiveFileView(
webhook *database.Webhook, target *database.Target,
webhook *database.Webhook, target *database.Target, now time.Time,
) *ArchiveFileView {
path := delivery.ArchivePath(h.dbMgr, webhook, target)
view := &ArchiveFileView{Name: filepath.Base(path)}
current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now)
if err != nil {
return h.archiveUnreadable(&ArchiveFileView{}, target, err)
}
file, err := delivery.StatArchive(path)
view := &ArchiveFileView{Name: filepath.Base(current)}
_, statErr := os.Stat(current)
if errors.Is(statErr, fs.ErrNotExist) {
view.Note = "not created yet"
}
files, err := delivery.StatArchive(
delivery.ArchivePath(h.dbMgr, webhook, target),
)
switch {
case errors.Is(err, fs.ErrNotExist):
view.Note = "not created yet"
// No files: no size or last write to show.
case err != nil:
h.log.Error(
"failed to read archive file metadata",
"target_id", target.ID,
"error", err,
)
view.Note = "could not be read"
return h.archiveUnreadable(view, target, err)
default:
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
view.Written = humanize.Time(file.Written)
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
view.Files = files.Files
view.Size = humanize.Bytes(uint64(files.Size)) //nolint:gosec // never negative
view.Written = humanize.Time(files.Written)
view.WrittenUTC = files.Written.UTC().Format(time.DateTime) + " UTC"
}
return view
}
// archiveUnreadable logs why a database target's archive files could
// not be described, and returns view saying so.
func (h *Handlers) archiveUnreadable(
view *ArchiveFileView, target *database.Target, err error,
) *ArchiveFileView {
h.log.Error(
"failed to read archive file metadata",
"target_id", target.ID,
"error", err,
)
view.Note = "could not be read"
return view
}