Persists every inbound webhook hit (project + site) so users can debug
"why didn't my deploy fire?" without grepping daemon logs. Surfaces a
14-day rolling history under the WebhookPanel on each project + site
detail page; refreshes every 30s while open. Daily cron prunes records
older than 14 days alongside the existing event log prune.
Schema:
- webhook_deliveries(id, target_type, target_id, target_name, received_at,
source_ip, signature_state, status_code, outcome, detail, body_size)
- indexes on (target_type,target_id,received_at) and (received_at)
Backend:
- store: WebhookDelivery model + Insert/List/Prune helpers
- webhook/handler: deferred recordDelivery() captures the final outcome
on every return path including HMAC rejects, image mismatch, no-stage,
auto_deploy=false, and successful deploys; signatureStateFor()
classifies "unconfigured" vs "missing" vs "invalid" vs "valid"
- api: GET /api/{projects,sites}/{id}/webhook/deliveries with
parseLimit() helper (default 50, max 200)
- main: daily prune cron retains the last 14 days
Frontend:
- WebhookDeliveryLog.svelte: panel with refresh button, status code +
outcome + signature badges, relative time tooltip-on-hover for
absolute time, source IP column
- Mounted below WebhookPanel on project + site detail pages
- en/ru i18n strings for outcome/signature enums and column labels
This commit is contained in:
@@ -159,6 +159,24 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
|
||||
// Webhook delivery audit log (2026-05-07). Persists every inbound
|
||||
// webhook request (project or site) with its outcome so users can
|
||||
// debug "why didn't my deploy fire?" without grepping daemon logs.
|
||||
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL DEFAULT '',
|
||||
target_name TEXT NOT NULL DEFAULT '',
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
signature_state TEXT NOT NULL DEFAULT '',
|
||||
status_code INTEGER NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
body_size INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
|
||||
}
|
||||
|
||||
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// WebhookDelivery is one persisted inbound webhook hit. Recorded after the
|
||||
// handler decides what to do so the row reflects the final outcome.
|
||||
type WebhookDelivery struct {
|
||||
ID int64 `json:"id"`
|
||||
TargetType string `json:"target_type"` // "project" or "site"
|
||||
TargetID string `json:"target_id"`
|
||||
TargetName string `json:"target_name"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
SignatureState string `json:"signature_state"` // "valid" / "invalid" / "missing" / "unconfigured"
|
||||
StatusCode int `json:"status_code"`
|
||||
Outcome string `json:"outcome"` // "deploy" / "skip" / "rejected" / etc.
|
||||
Detail string `json:"detail"`
|
||||
BodySize int `json:"body_size"`
|
||||
}
|
||||
|
||||
// InsertWebhookDelivery persists a single webhook delivery record. Best-effort
|
||||
// — failures here must not block the actual webhook handler, so callers
|
||||
// should log and continue rather than propagate.
|
||||
func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO webhook_deliveries
|
||||
(target_type, target_id, target_name, source_ip, signature_state,
|
||||
status_code, outcome, detail, body_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
d.TargetType, d.TargetID, d.TargetName, d.SourceIP, d.SignatureState,
|
||||
d.StatusCode, d.Outcome, d.Detail, d.BodySize,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert webhook delivery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a
|
||||
// specific target. Used by the per-entity panel on the project / site detail
|
||||
// pages.
|
||||
func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, target_type, target_id, target_name, received_at, source_ip,
|
||||
signature_state, status_code, outcome, detail, body_size
|
||||
FROM webhook_deliveries
|
||||
WHERE target_type = ? AND target_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
targetType, targetID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query webhook deliveries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []WebhookDelivery{}
|
||||
for rows.Next() {
|
||||
var d WebhookDelivery
|
||||
if err := rows.Scan(&d.ID, &d.TargetType, &d.TargetID, &d.TargetName, &d.ReceivedAt,
|
||||
&d.SourceIP, &d.SignatureState, &d.StatusCode, &d.Outcome, &d.Detail, &d.BodySize); err != nil {
|
||||
return nil, fmt.Errorf("scan webhook delivery: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PruneWebhookDeliveriesBefore deletes rows older than the given timestamp.
|
||||
// Returns the number of rows removed. Run on the same daily cron as the
|
||||
// event log to keep the table from growing without bound.
|
||||
func (s *Store) PruneWebhookDeliveriesBefore(beforeTS string) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM webhook_deliveries WHERE received_at < ?`, beforeTS)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune webhook deliveries: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user