Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
075044f67d | ||
|
|
f9aba94ef9 |
@@ -25,7 +25,6 @@ COPY . .
|
|||||||
# would need a docker daemon inside the build. Keep these steps in step with
|
# would need a docker daemon inside the build. Keep these steps in step with
|
||||||
# Dockerfile.lint, including --network=none (see its header for why).
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN script/assets
|
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ FROM deps AS lint
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
|
|
||||||
# it the static package does not compile and cannot be linted.
|
|
||||||
RUN script/assets
|
|
||||||
|
|
||||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
# disable a setting without a word. `config verify` is what catches that.
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
|
|||||||
@@ -1050,8 +1050,7 @@ Archive databases are the one exception the service is built for: the
|
|||||||
archive writer closes and reopens its handle around writes (debounced
|
archive writer closes and reopens its handle around writes (debounced
|
||||||
to at most one reopen per second), so an operator can move an
|
to at most one reopen per second), so an operator can move an
|
||||||
`archive-….db` away for offline retention while the service runs,
|
`archive-….db` away for offline retention while the service runs,
|
||||||
and it is recreated on the next write. The webhook page names each
|
and it is recreated on the next write. See
|
||||||
`database` target's archive file. See
|
|
||||||
[Database Architecture](#database-architecture). That is a
|
[Database Architecture](#database-architecture). That is a
|
||||||
move-the-file-away workflow, not a substitute for the backup procedures
|
move-the-file-away workflow, not a substitute for the backup procedures
|
||||||
above.
|
above.
|
||||||
@@ -1352,9 +1351,7 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root
|
|||||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||||
`.dockerignore` keeps any host copy out of the build context. `static/static.go`
|
`.dockerignore` keeps any host copy out of the build context.
|
||||||
names every file it embeds, so a build that skips the extraction, such as a
|
|
||||||
bare `go build`, fails with an error naming `js/alpine.min.js`.
|
|
||||||
|
|
||||||
To move to a new version: download
|
To move to a new version: download
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
|
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
|
||||||
@@ -1882,19 +1879,16 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
|||||||
| `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 |
|
| `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` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||||
rates the `deleted_at` index, which every live row matches, above an index on
|
rates the `deleted_at` index, which every live row matches, above an index on
|
||||||
a column matched against several values or compared with a range. So every
|
a column matched against several values or compared with a range. So every
|
||||||
index but the last also covers `deleted_at`. It comes second in the `event_id`
|
index but the last also covers `deleted_at`. It comes second, so that
|
||||||
and `delivery_id` indexes, so that retention can use them without it. The event
|
retention can use the index without it, except in `events`, where the
|
||||||
log's count, the one query on the `resubmitted_from_id` index, always carries
|
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||||
`deleted_at IS NULL` and uses both columns. In the statistics' `events` index
|
range only on the last column it uses.
|
||||||
`deleted_at` comes first, because they compare `created_at` with a range (`>=`)
|
|
||||||
and SQLite narrows by a range only on the last column it uses.
|
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
@@ -2040,14 +2034,6 @@ Because each `database` target has its own archive file, a target's
|
|||||||
webhook with different expiries keep two archives, each pruned on its
|
webhook with different expiries keep two archives, each pruned on its
|
||||||
own schedule.
|
own schedule.
|
||||||
|
|
||||||
The webhook page shows, for each `database` target, its archive file's
|
|
||||||
name, its size on disk and when it was last written. The size counts
|
|
||||||
the `.db` and its `-wal` together, and the last write is the later of
|
|
||||||
their two modification times, since a write lands in the `-wal` first.
|
|
||||||
Both are read from the files' metadata; the archive is never opened.
|
|
||||||
Before the first write, and after the file has been moved away, the page
|
|
||||||
shows `not created yet` beside the name.
|
|
||||||
|
|
||||||
Each `database` target on the webhook page has a **Download** button,
|
Each `database` target on the webhook page has a **Download** button,
|
||||||
which returns its archive as one gzipped JSON file,
|
which returns its archive as one gzipped JSON file,
|
||||||
`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the
|
`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the
|
||||||
@@ -3396,9 +3382,8 @@ version is fixed independently of the compiler's:
|
|||||||
|
|
||||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check`, then `script/assets` to extract Alpine.js from
|
`make fmt-check`, then `golangci-lint config verify` and
|
||||||
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
|
`golangci-lint run`, both with `--network=none`.
|
||||||
both with `--network=none`.
|
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||||
stage passing (it copies a file from it), runs `make test` and
|
stage passing (it copies a file from it), runs `make test` and
|
||||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||||
|
|||||||
@@ -199,40 +199,6 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
"(deleted_at=? AND created_at>?)")
|
"(deleted_at=? AND created_at>?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestResubmitCountUsesItsIndex does the same for the event log's count
|
|
||||||
// of the events resubmitted from each of a page's events (resubmitCounts
|
|
||||||
// in the handlers). It passes a full page of 25 ids: with an index on
|
|
||||||
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
|
|
||||||
// the deleted_at index from five.
|
|
||||||
func TestResubmitCountUsesItsIndex(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})
|
|
||||||
|
|
||||||
page := make([]string, 25)
|
|
||||||
for i := range page {
|
|
||||||
page[i] = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
var counts []struct{ Total int }
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
|
||||||
Select("resubmitted_from_id, count(*) AS total").
|
|
||||||
Where("resubmitted_from_id IN ?", page).
|
|
||||||
Group("resubmitted_from_id").Find(&counts),
|
|
||||||
"idx_events_resubmitted_from_id "+
|
|
||||||
"(resubmitted_from_id=? AND deleted_at=?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ type Event struct {
|
|||||||
// narrows by a < only on the last column it uses. Its final delete
|
// narrows by a < only on the last column it uses. Its final delete
|
||||||
// has no deleted_at condition and uses the index on created_at
|
// has no deleted_at condition and uses the index on created_at
|
||||||
// alone. The other tables keep the unindexed BaseModel created_at.
|
// alone. The other tables keep the unindexed BaseModel created_at.
|
||||||
// DeletedAt is also the second column of the resubmitted_from_id
|
|
||||||
// index, for the reason DeliveryResult gives.
|
|
||||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"`
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||||
@@ -44,7 +42,7 @@ type Event struct {
|
|||||||
// existed. It is not a foreign key: the source event can be
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to, so
|
// Relations. No model marshals the record it belongs to, so
|
||||||
// Webhook and Entrypoint are left out of the JSON.
|
// Webhook and Entrypoint are left out of the JSON.
|
||||||
|
|||||||
@@ -515,47 +515,6 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
|
||||||
// file says about it.
|
|
||||||
type ArchiveFileInfo struct {
|
|
||||||
// Size is the bytes on disk of the file and its -wal together.
|
|
||||||
Size int64
|
|
||||||
|
|
||||||
// Written is when the file or its -wal was last modified, whichever
|
|
||||||
// is later: a write lands in the -wal first.
|
|
||||||
Written time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatArchive reads the metadata of the archive file at path and of
|
|
||||||
// its -wal, without opening the archive. With no file at path, which is
|
|
||||||
// so before the first write and after the operator moved it away, the
|
|
||||||
// error wraps fs.ErrNotExist.
|
|
||||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
|
||||||
file, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
|
||||||
|
|
||||||
wal, err := os.Stat(path + "-wal")
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return ArchiveFileInfo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Size += wal.Size()
|
|
||||||
|
|
||||||
if wal.ModTime().After(info.Written) {
|
|
||||||
info.Written = wal.ModTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileExists reports whether a path currently exists.
|
// fileExists reports whether a path currently exists.
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -184,49 +183,6 @@ func TestArchiveWriter_RecreatesAfterRemoval(
|
|||||||
assert.Equal(t, "b", got[0].EventID)
|
assert.Equal(t, "b", got[0].EventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStatArchive proves StatArchive finds no file before the first
|
|
||||||
// write; after a write still held in the -wal, counts the -wal in the
|
|
||||||
// size and takes its later time as the last write; and finds no file
|
|
||||||
// again once the file has been moved away.
|
|
||||||
func TestStatArchive(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
|
||||||
|
|
||||||
_, err := delivery.StatArchive(path)
|
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
|
|
||||||
// With the clock stopped, the reopen debounce never passes, so
|
|
||||||
// the handle stays open after the write.
|
|
||||||
stopped := time.Now()
|
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
|
||||||
w.SetNow(func() time.Time { return stopped })
|
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
|
|
||||||
|
|
||||||
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
||||||
earlier := written.Add(-time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(path, earlier, earlier))
|
|
||||||
require.NoError(t, os.Chtimes(path+"-wal", written, written))
|
|
||||||
|
|
||||||
file, err := os.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
wal, err := os.Stat(path + "-wal")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Positive(t, wal.Size())
|
|
||||||
|
|
||||||
got, err := delivery.StatArchive(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
|
||||||
assert.True(t, written.Equal(got.Written), got.Written)
|
|
||||||
|
|
||||||
removeArchiveFiles(t, path)
|
|
||||||
|
|
||||||
_, err = delivery.StatArchive(path)
|
|
||||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
// target's stored config blob holds a credential, and it
|
// target's stored config blob holds a credential, and it
|
||||||
// must never reach a template.
|
// must never reach a template.
|
||||||
"Entrypoints": NewEntrypointViews(entrypoints),
|
"Entrypoints": NewEntrypointViews(entrypoints),
|
||||||
"Targets": h.targetRows(&webhook, targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TargetRowView is one row of the target list on a webhook's page.
|
|
||||||
type TargetRowView struct {
|
|
||||||
delivery.TargetView
|
|
||||||
|
|
||||||
// Archive is a database target's archive file, and nil for a target
|
|
||||||
// of any other type.
|
|
||||||
Archive *ArchiveFileView
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArchiveFileView is what a database target's row shows about its
|
|
||||||
// archive file.
|
|
||||||
type ArchiveFileView struct {
|
|
||||||
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 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.
|
|
||||||
Size string
|
|
||||||
Written string
|
|
||||||
WrittenUTC string
|
|
||||||
}
|
|
||||||
|
|
||||||
// targetRows projects a webhook's targets for the target list on its
|
|
||||||
// page.
|
|
||||||
func (h *Handlers) targetRows(
|
|
||||||
webhook *database.Webhook, targets []database.Target,
|
|
||||||
) []TargetRowView {
|
|
||||||
views := delivery.NewTargetViews(targets)
|
|
||||||
rows := make([]TargetRowView, len(views))
|
|
||||||
|
|
||||||
// NewTargetViews returns one view per target, in order.
|
|
||||||
for i := range views {
|
|
||||||
rows[i].TargetView = views[i]
|
|
||||||
|
|
||||||
if targets[i].Type == database.TargetTypeDatabase {
|
|
||||||
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (h *Handlers) archiveFileView(
|
|
||||||
webhook *database.Webhook, target *database.Target,
|
|
||||||
) *ArchiveFileView {
|
|
||||||
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
|
||||||
view := &ArchiveFileView{Name: filepath.Base(path)}
|
|
||||||
|
|
||||||
file, err := delivery.StatArchive(path)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
|
||||||
view.Note = "not created yet"
|
|
||||||
case err != nil:
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read archive file metadata",
|
|
||||||
"target_id", target.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
view.Note = "could not be read"
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_ShowsArchiveFile proves a database target's
|
|
||||||
// row names its archive file and says "not created yet" before the
|
|
||||||
// first write, adds the file's size and last write once it has one row,
|
|
||||||
// and says "not created yet" again once the file has been moved away.
|
|
||||||
// A target of another type shows no archive file.
|
|
||||||
func TestHandleSourceDetail_ShowsArchiveFile(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)
|
|
||||||
archive := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
path := delivery.ArchivePath(dbMgr, wh, archive)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
|
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
|
||||||
assert.Contains(t, body, "not created yet")
|
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
|
||||||
|
|
||||||
seedArchive(t, path, 1, 100)
|
|
||||||
|
|
||||||
file, err := os.Stat(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
|
||||||
assert.NotContains(t, body, "not created yet")
|
|
||||||
assert.Regexp(t,
|
|
||||||
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
|
||||||
)
|
|
||||||
assert.Contains(t, body,
|
|
||||||
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, os.Rename(path, filepath.Join(t.TempDir(), "moved.db")))
|
|
||||||
|
|
||||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
|
||||||
assert.Contains(t, body, "not created yet")
|
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
|
||||||
}
|
|
||||||
+2
-5
@@ -5,10 +5,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Static holds the CSS and JavaScript files the web UI's pages load. They
|
// Static holds the embedded CSS and JavaScript files for the web UI.
|
||||||
// are named one by one so that a missing js/alpine.min.js, which make
|
|
||||||
// assets extracts and git does not track, fails the build instead of
|
|
||||||
// leaving the pages without Alpine.js.
|
|
||||||
//
|
//
|
||||||
//go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js
|
//go:embed css js
|
||||||
var Static embed.FS
|
var Static embed.FS
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
|
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
|
||||||
<select x-ref="type" aria-label="Target type" class="input text-sm w-32">
|
<select x-ref="type" aria-label="Target type" class="input text-sm w-40">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Database</option>
|
||||||
@@ -226,23 +226,6 @@
|
|||||||
<span>{{.Value}}</span>
|
<span>{{.Value}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{with .Archive}}
|
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
|
||||||
<span class="font-medium text-gray-700">Archive File:</span>
|
|
||||||
<span class="break-all">{{.Name}}</span>
|
|
||||||
{{with .Note}}<span>({{.}})</span>{{end}}
|
|
||||||
</div>
|
|
||||||
{{if .Size}}
|
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
|
||||||
<span class="font-medium text-gray-700">Archive Size:</span>
|
|
||||||
<span>{{.Size}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
|
||||||
<span class="font-medium text-gray-700">Last Written:</span>
|
|
||||||
<span title="{{.WrittenUTC}}">{{.Written}}</span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user