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:
@@ -249,6 +249,7 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
|
||||
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
|
||||
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
|
||||
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
|
||||
|
||||
// Per-project outgoing-webhook signing & test.
|
||||
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
||||
@@ -332,6 +333,7 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
|
||||
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
|
||||
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
|
||||
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
|
||||
|
||||
// Per-site outgoing-webhook signing & test.
|
||||
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -248,6 +249,65 @@ func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.R
|
||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries.
|
||||
// Returns the most recent webhook deliveries for the project so users can
|
||||
// debug "why didn't my deploy fire?" without grepping daemon logs.
|
||||
func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
|
||||
deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit)
|
||||
if err != nil {
|
||||
slog.Error("list project webhook deliveries", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, deliveries)
|
||||
}
|
||||
|
||||
// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries.
|
||||
func (s *Server) listStaticSiteWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
|
||||
deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit)
|
||||
if err != nil {
|
||||
slog.Error("list site webhook deliveries", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, deliveries)
|
||||
}
|
||||
|
||||
// parseLimit clamps a query-string limit to [1, max], falling back to def.
|
||||
func parseLimit(raw string, def, max int) int {
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
if n > max {
|
||||
return max
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
|
||||
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
Reference in New Issue
Block a user