Files
webhooker/internal/handlers/target_list.go
T
sneak 92dbfc8996
check / check (push) Successful in 3m19s
Event log: show attempt and delivery times, label replays, zone event times (closes #386)
Each attempt shows when it was recorded and each delivery when it was
created, in the event log and on the event's page. The event log's
event times read as the recent events list does: how long ago, with
the full UTC time on hover.

A delivery records whether Replay created it, in a new replay column
added to the delivery model in place. Such a delivery is labelled a
replay in the event's summary line and in both pages' delivery lists.

Model: opus-5-5
2026-10-03 00:52:03 +00:00

260 lines
7.3 KiB
Go

package handlers
import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"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
// Deliveries counts the target's delivered and failed deliveries,
// 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 *ArchiveFileView
// Paused is set while the target's circuit breaker is turning its
// deliveries away, and nil otherwise.
Paused *PausedView
}
// PausedView is a target's circuit breaker turning deliveries away.
// While the breaker is open, Until is a time in UTC, and Relative how
// long that is from now: on the target's row, when the cooldown ends;
// on a delivery, the earliest it can be tried next. While it is
// half-open both are empty: the cooldown has ended, and the target's
// deliveries are held while one delivery tests whether the target has
// recovered.
type PausedView struct {
Until string
Relative string
}
// pausedView reads the target's circuit breaker for its row, and
// returns nil when the breaker lets the target's deliveries through.
func (h *Handlers) pausedView(targetID string) *PausedView {
state, cooldown := h.breakers.StateAndCooldown(targetID)
switch {
case state == delivery.CircuitHalfOpen:
return &PausedView{}
case state == delivery.CircuitOpen && cooldown > 0:
return newPausedView(time.Now().Add(cooldown))
default:
return nil
}
}
// deliveryPausedView reads the circuit breaker of a retrying delivery's
// target. While it is open, it says the earliest the delivery can be
// tried next: the later of the cooldown's end and the end of the
// delivery's own backoff after its last attempt. It is only the
// earliest: when the cooldown ends, one of the target's waiting
// deliveries is sent to test it while the others wait at least one more
// cooldown. Otherwise it returns nil, half-open included, since the
// delivery may then be the one being sent to test the target.
func (h *Handlers) deliveryPausedView(
targetID string, attempts []deliveryResultRow,
) *PausedView {
state, cooldown := h.breakers.StateAndCooldown(targetID)
if state != delivery.CircuitOpen || cooldown <= 0 {
return nil
}
next := time.Now().Add(cooldown)
if len(attempts) > 0 {
last := attempts[len(attempts)-1]
backoffEnd := last.CreatedAt.Add(delivery.Backoff(last.AttemptNum))
if backoffEnd.After(next) {
next = backoffEnd
}
}
return newPausedView(next)
}
// newPausedView is a PausedView of deliveries paused until the given
// time. A time not on the current UTC day is written with its date.
func newPausedView(until time.Time) *PausedView {
until = until.UTC()
layout := time.TimeOnly
if until.Format(time.DateOnly) != time.Now().UTC().Format(time.DateOnly) {
layout = time.DateTime
}
return &PausedView{
Until: until.Format(layout) + " UTC",
Relative: humanize.Time(until),
}
}
// TargetDeliveries is how many of a target's deliveries became
// delivered and how many failed: in total, which retention does not
// reduce, and in the last 24 hours. Deliveries still pending or
// retrying count in neither.
type TargetDeliveries struct {
Delivered int64
Failed int64
DeliveredLast24Hours int64
FailedLast24Hours int64
}
// 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))
deliveries, err := h.loadTargetDeliveries(webhook.ID)
if err != nil {
h.log.Error(
"failed to read target delivery counts",
"webhook_id", webhook.ID,
"error", err,
)
}
// NewTargetViews returns one view per target, in order.
for i := range views {
rows[i].TargetView = views[i]
if err == nil {
counts := deliveries[targets[i].ID]
rows[i].Deliveries = &counts
}
if targets[i].Type == database.TargetTypeDatabase {
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
}
rows[i].Paused = h.pausedView(targets[i].ID)
}
return rows
}
// loadTargetDeliveries reads the delivery counts of a webhook's targets
// from its event database, keyed by target. A target with no deliveries
// is left out, and so is every target when the event database does not
// exist yet, since opening it would create it.
func (h *Handlers) loadTargetDeliveries(
webhookID string,
) (map[string]TargetDeliveries, error) {
if !h.dbMgr.DBExists(webhookID) {
return map[string]TargetDeliveries{}, nil
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
return nil, err
}
return readTargetDeliveries(webhookDB, time.Now())
}
// readTargetDeliveries counts each target's deliveries that became
// delivered and those that failed: in total from the targets' running
// totals, and in the 24 hours before now from the deliveries' status
// index. Each is one query for all the targets, and neither reads every
// stored delivery.
func readTargetDeliveries(
db *gorm.DB, now time.Time,
) (map[string]TargetDeliveries, error) {
var totals []database.TargetTotals
err := db.Find(&totals).Error
if err != nil {
return nil, fmt.Errorf("reading target totals: %w", err)
}
lastDay, err := finishedByTarget(db, now.Add(-longWindow))
if err != nil {
return nil, err
}
byTarget := make(map[string]TargetDeliveries, len(totals))
for _, total := range totals {
byTarget[total.TargetID] = TargetDeliveries{
Delivered: total.Delivered,
Failed: total.Failed,
}
}
for _, finished := range lastDay {
counts := byTarget[finished.TargetID]
counts.DeliveredLast24Hours = finished.Delivered
counts.FailedLast24Hours = finished.Failed
byTarget[finished.TargetID] = counts
}
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.
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
}