feat: implement core webhook engine, delivery system, and management UI (Phase 2)
All checks were successful
check / check (push) Successful in 1m49s

- Webhook reception handler: look up entrypoint by UUID, verify active,
  capture full HTTP request (method, headers, body, content-type), create
  Event record, queue Delivery records for each active Target, return 200 OK.
  Handles edge cases: unknown UUID → 404, inactive → 410, oversized → 413.

- Delivery engine (internal/delivery): fx-managed background goroutine that
  polls for pending/retrying deliveries and dispatches to target type handlers.
  Graceful shutdown via context cancellation.

- Target type implementations:
  - HTTP: fire-and-forget POST with original headers forwarding
  - Retry: exponential backoff (1s, 2s, 4s...) up to max_retries
  - Database: immediate success (event already stored)
  - Log: slog output with event details

- Webhook management pages with Tailwind CSS + Alpine.js:
  - List (/sources): webhooks with entrypoint/target/event counts
  - Create (/sources/new): form with auto-created default entrypoint
  - Detail (/source/{id}): config, entrypoints, targets, recent events
  - Edit (/source/{id}/edit): name, description, retention_days
  - Delete (/source/{id}/delete): soft-delete with child records
  - Add Entrypoint (/source/{id}/entrypoints): inline form
  - Add Target (/source/{id}/targets): type-aware form
  - Event Log (/source/{id}/logs): paginated with delivery status

- Updated README: marked completed items, updated naming conventions
  table, added delivery engine to package layout and DI docs, updated
  column names to reflect entity rename.

- Rebuilt Tailwind CSS for new template classes.

Part of: #15
This commit is contained in:
clawbot 2026-03-01 16:14:28 -08:00
parent 853f25ee67
commit 7f8469a0f2
13 changed files with 1395 additions and 114 deletions

131
README.md
View File

@ -164,19 +164,14 @@ It uses:
### Naming Conventions
This README uses the target naming scheme for the application's core
entities. The current codebase uses older names that will be updated in
a future refactor (see
The codebase uses consistent naming throughout (rename completed in
[issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)):
| README (target name) | Current code name | Description |
| --------------------- | ----------------- | ----------- |
| **Webhook** | `Processor` | Top-level configuration entity grouping entrypoints and targets |
| **Entrypoint** | `Webhook` | A receiver URL where external services POST events |
| **Target** | `Target` | A delivery destination for events |
Throughout this document, the target names are used. The code rename is
tracked separately.
| Entity | Description |
| ---------------- | ----------- |
| **Webhook** | Top-level configuration entity grouping entrypoints and targets |
| **Entrypoint** | A receiver URL where external services POST events |
| **Target** | A delivery destination for events |
### Data Model
@ -227,10 +222,10 @@ password logged to stdout.
#### Webhook
The top-level configuration entity (currently called "Processor" in
code). A webhook groups together one or more entrypoints (receiver URLs)
and one or more targets (delivery destinations) into a logical unit. A
user creates a webhook to set up event routing.
The top-level configuration entity. A webhook groups together one or
more entrypoints (receiver URLs) and one or more targets (delivery
destinations) into a logical unit. A user creates a webhook to set up
event routing.
| Field | Type | Description |
| ---------------- | ------- | ----------- |
@ -247,15 +242,15 @@ webhook's dedicated database before automatic cleanup.
#### Entrypoint
A receiver URL where external services POST webhook events (currently
called "Webhook" in code). Each entrypoint has a unique UUID-based path.
A receiver URL where external services POST webhook events. Each
entrypoint has a unique UUID-based path.
When an HTTP request arrives at an entrypoint's path, webhooker captures
the full request and creates an Event.
| Field | Type | Description |
| -------------- | ------- | ----------- |
| `id` | UUID | Primary key |
| `processor_id` | UUID | Foreign key → Webhook |
| `webhook_id` | UUID | Foreign key → Webhook |
| `path` | string | Unique URL path (UUID-based, e.g. `/webhook/{uuid}`) |
| `description` | string | Optional description |
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
@ -275,7 +270,7 @@ events should be forwarded.
| Field | Type | Description |
| ---------------- | ---------- | ----------- |
| `id` | UUID | Primary key |
| `processor_id` | UUID | Foreign key → Webhook |
| `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `retry`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) |
@ -321,8 +316,8 @@ data for replay and auditing.
| Field | Type | Description |
| -------------- | ------ | ----------- |
| `id` | UUID | Primary key |
| `processor_id` | UUID | Foreign key → Webhook |
| `webhook_id` | UUID | Foreign key → Entrypoint |
| `webhook_id` | UUID | Foreign key → Webhook |
| `entrypoint_id` | UUID | Foreign key → Entrypoint |
| `method` | string | HTTP method (POST, PUT, etc.) |
| `headers` | JSON | Complete request headers |
| `body` | text | Raw request body |
@ -406,8 +401,8 @@ configuration data and per-webhook databases for event storage.
**Main Application Database** — will store:
- **Users** — accounts and Argon2id password hashes
- **Webhooks** (Processors) — webhook configurations
- **Entrypoints** (Webhooks) — receiver URL definitions
- **Webhooks** — webhook configurations
- **Entrypoints** — receiver URL definitions
- **Targets** — delivery destination configurations
- **APIKeys** — programmatic access credentials
@ -515,6 +510,8 @@ against a misbehaving sender).
| `POST` | `/source/{id}/edit` | Edit webhook submission |
| `POST` | `/source/{id}/delete` | Delete webhook |
| `GET` | `/source/{id}/logs` | Webhook event logs |
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
| `POST` | `/source/{id}/targets` | Add target to webhook |
#### Infrastructure Endpoints
@ -554,8 +551,8 @@ webhooker/
│ │ ├── database.go # GORM connection, migrations, admin seed
│ │ ├── models.go # AutoMigrate for all models
│ │ ├── model_user.go # User entity
│ │ ├── model_processor.go # Webhook entity (to be renamed)
│ │ ├── model_webhook.go # Entrypoint entity (to be renamed)
│ │ ├── model_webhook.go # Webhook entity
│ │ ├── model_entrypoint.go # Entrypoint entity
│ │ ├── model_target.go # Target entity and TargetType enum
│ │ ├── model_event.go # Event entity
│ │ ├── model_delivery.go # Delivery entity and DeliveryStatus enum
@ -564,13 +561,15 @@ webhooker/
│ │ └── password.go # Argon2id hashing and verification
│ ├── globals/
│ │ └── globals.go # Build-time variables (appname, version, arch)
│ ├── delivery/
│ │ └── engine.go # Background delivery engine (fx lifecycle)
│ ├── handlers/
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
│ │ ├── auth.go # Login, logout handlers
│ │ ├── healthcheck.go # Health check handler
│ │ ├── index.go # Index page handler
│ │ ├── profile.go # User profile handler
│ │ ├── source_management.go # Webhook CRUD handlers (stubs)
│ │ ├── source_management.go # Webhook CRUD handlers
│ │ └── webhook.go # Webhook receiver handler
│ ├── healthcheck/
│ │ └── healthcheck.go # Health check service (uptime, version)
@ -610,10 +609,11 @@ Components are wired via Uber fx in this order:
6. `session.New` — Cookie-based session manager
7. `handlers.New` — HTTP handlers
8. `middleware.New` — HTTP middleware
9. `server.New` — HTTP server and router
9. `delivery.New` — Background delivery engine
10. `server.New` — HTTP server and router
The server starts via `fx.Invoke(func(*server.Server) {})` which
triggers the fx lifecycle hooks in dependency order.
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine)
{})` which triggers the fx lifecycle hooks in dependency order.
### Middleware Stack
@ -669,58 +669,57 @@ linted, tested, and compiled.
## TODO
### Phase 1: Core Webhook Engine
- [ ] Implement webhook reception and event storage at `/webhook/{uuid}`
- [ ] Build event processing and target delivery engine
- [ ] Implement HTTP target type (fire-and-forget POST)
- [ ] Implement retry target type (exponential backoff)
- [ ] Implement database target type (store only)
- [ ] Implement log target type (console output)
### Completed: Code Quality (Phase 1 of MVP)
- [x] Rename Processor → Webhook, Webhook → Entrypoint in code
([#12](https://git.eeqj.de/sneak/webhooker/issues/12))
- [x] Embed templates via `//go:embed`
([#7](https://git.eeqj.de/sneak/webhooker/issues/7))
- [x] Use `slog.LevelVar` for dynamic log level switching
([#8](https://git.eeqj.de/sneak/webhooker/issues/8))
- [x] Simplify configuration to prefer environment variables
([#10](https://git.eeqj.de/sneak/webhooker/issues/10))
- [x] Remove redundant `godotenv/autoload` import
([#11](https://git.eeqj.de/sneak/webhooker/issues/11))
- [x] Implement authentication middleware for protected routes
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
- [x] Replace Bootstrap with Tailwind CSS + Alpine.js
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
### Completed: Core Webhook Engine (Phase 2 of MVP)
- [x] Implement webhook reception and event storage at `/webhook/{uuid}`
- [x] Build event processing and target delivery engine
- [x] Implement HTTP target type (fire-and-forget POST)
- [x] Implement retry target type (exponential backoff)
- [x] Implement database target type (store only)
- [x] Implement log target type (console output)
- [x] Webhook management pages (list, create, edit, delete)
- [x] Webhook request log viewer with pagination
- [x] Entrypoint and target management UI
### Remaining: Core Features
- [ ] Per-webhook rate limiting in the receiver handler
- [ ] Webhook signature verification (GitHub, Stripe formats)
### Phase 2: Database Separation
- [ ] Split into main application DB + per-webhook event DBs
- [ ] Automatic event retention cleanup based on `retention_days`
- [ ] Per-webhook database lifecycle management (create on webhook
creation, delete on webhook removal)
### Phase 3: Security & Infrastructure
- [ ] Implement authentication middleware for protected routes
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
- [ ] Security headers (HSTS, CSP, X-Frame-Options)
- [ ] CSRF protection for forms
- [ ] Session expiration and "remember me"
- [ ] Password change/reset flow
- [ ] API key authentication for programmatic access
### Phase 4: Web UI
- [ ] Webhook management pages (list, create, edit, delete)
- [ ] Webhook request log viewer with filtering
- [ ] Delivery status and retry management UI
- [ ] Manual event redelivery
- [ ] Analytics dashboard (success rates, response times)
- [ ] Replace Bootstrap with Tailwind CSS + Alpine.js
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
- [ ] Delivery status and retry management UI
### Phase 5: REST API
### Remaining: Database Separation
- [ ] Split into main application DB + per-webhook event DBs
- [ ] Automatic event retention cleanup based on `retention_days`
- [ ] Per-webhook database lifecycle management (create on webhook
creation, delete on webhook removal)
### Remaining: REST API
- [ ] RESTful CRUD for webhooks, entrypoints, targets
- [ ] Event viewing and filtering endpoints
- [ ] Event redelivery endpoint
- [ ] OpenAPI specification
### Phase 6: Code Quality
- [ ] Rename Processor → Webhook, Webhook → Entrypoint in code
([#12](https://git.eeqj.de/sneak/webhooker/issues/12))
- [ ] Embed templates via `//go:embed`
([#7](https://git.eeqj.de/sneak/webhooker/issues/7))
- [ ] Use `slog.LevelVar` for dynamic log level switching
([#8](https://git.eeqj.de/sneak/webhooker/issues/8))
- [ ] Simplify configuration to prefer environment variables
([#10](https://git.eeqj.de/sneak/webhooker/issues/10))
- [ ] Remove redundant `godotenv/autoload` import
([#11](https://git.eeqj.de/sneak/webhooker/issues/11))
### Future
- [ ] Email delivery target type
- [ ] SNS, S3, Slack delivery targets

View File

@ -6,6 +6,7 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
@ -36,8 +37,9 @@ func main() {
session.New,
handlers.New,
middleware.New,
delivery.New,
server.New,
),
fx.Invoke(func(*server.Server) {}),
fx.Invoke(func(*server.Server, *delivery.Engine) {}),
).Run()
}

383
internal/delivery/engine.go Normal file
View File

@ -0,0 +1,383 @@
package delivery
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/logger"
)
const (
// pollInterval is how often the engine checks for pending deliveries.
pollInterval = 2 * time.Second
// httpClientTimeout is the timeout for outbound HTTP requests.
httpClientTimeout = 30 * time.Second
// maxBodyLog is the maximum response body length to store in DeliveryResult.
maxBodyLog = 4096
)
// HTTPTargetConfig holds configuration for http and retry target types.
type HTTPTargetConfig struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
}
// EngineParams are the fx dependencies for the delivery engine.
//
//nolint:revive // EngineParams is a standard fx naming convention
type EngineParams struct {
fx.In
DB *database.Database
Logger *logger.Logger
}
// Engine processes queued deliveries in the background.
type Engine struct {
db *gorm.DB
log *slog.Logger
client *http.Client
cancel context.CancelFunc
wg sync.WaitGroup
}
// New creates and registers the delivery engine with the fx lifecycle.
func New(lc fx.Lifecycle, params EngineParams) *Engine {
e := &Engine{
db: params.DB.DB(),
log: params.Logger.Get(),
client: &http.Client{
Timeout: httpClientTimeout,
},
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
e.start()
return nil
},
OnStop: func(_ context.Context) error {
e.stop()
return nil
},
})
return e
}
func (e *Engine) start() {
ctx, cancel := context.WithCancel(context.Background())
e.cancel = cancel
e.wg.Add(1)
go e.run(ctx)
e.log.Info("delivery engine started")
}
func (e *Engine) stop() {
e.log.Info("delivery engine stopping")
e.cancel()
e.wg.Wait()
e.log.Info("delivery engine stopped")
}
func (e *Engine) run(ctx context.Context) {
defer e.wg.Done()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.processPending(ctx)
}
}
}
func (e *Engine) processPending(ctx context.Context) {
var deliveries []database.Delivery
result := e.db.
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).
Preload("Target").
Preload("Event").
Find(&deliveries)
if result.Error != nil {
e.log.Error("failed to query pending deliveries", "error", result.Error)
return
}
for i := range deliveries {
select {
case <-ctx.Done():
return
default:
e.processDelivery(ctx, &deliveries[i])
}
}
}
func (e *Engine) processDelivery(ctx context.Context, d *database.Delivery) {
switch d.Target.Type {
case database.TargetTypeHTTP:
e.deliverHTTP(ctx, d)
case database.TargetTypeRetry:
e.deliverRetry(ctx, d)
case database.TargetTypeDatabase:
e.deliverDatabase(d)
case database.TargetTypeLog:
e.deliverLog(d)
default:
e.log.Error("unknown target type",
"target_id", d.TargetID,
"type", d.Target.Type,
)
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
}
}
func (e *Engine) deliverHTTP(_ context.Context, d *database.Delivery) {
cfg, err := e.parseHTTPConfig(d.Target.Config)
if err != nil {
e.log.Error("invalid HTTP target config",
"target_id", d.TargetID,
"error", err,
)
e.recordResult(d, 1, false, 0, "", err.Error(), 0)
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
return
}
statusCode, respBody, duration, err := e.doHTTPRequest(cfg, &d.Event)
success := err == nil && statusCode >= 200 && statusCode < 300
errMsg := ""
if err != nil {
errMsg = err.Error()
}
e.recordResult(d, 1, success, statusCode, respBody, errMsg, duration)
if success {
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
} else {
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
}
}
func (e *Engine) deliverRetry(_ context.Context, d *database.Delivery) {
cfg, err := e.parseHTTPConfig(d.Target.Config)
if err != nil {
e.log.Error("invalid retry target config",
"target_id", d.TargetID,
"error", err,
)
e.recordResult(d, 1, false, 0, "", err.Error(), 0)
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
return
}
// Determine attempt number from existing results
var resultCount int64
e.db.Model(&database.DeliveryResult{}).Where("delivery_id = ?", d.ID).Count(&resultCount)
attemptNum := int(resultCount) + 1
// Check if we should wait before retrying (exponential backoff)
if attemptNum > 1 {
var lastResult database.DeliveryResult
lookupErr := e.db.Where("delivery_id = ?", d.ID).Order("created_at DESC").First(&lastResult).Error
if lookupErr == nil {
shift := attemptNum - 2
if shift > 30 {
shift = 30
}
backoff := time.Duration(1<<uint(shift)) * time.Second //nolint:gosec // bounded above
nextAttempt := lastResult.CreatedAt.Add(backoff)
if time.Now().UTC().Before(nextAttempt) {
// Not time to retry yet
return
}
}
}
statusCode, respBody, duration, err := e.doHTTPRequest(cfg, &d.Event)
success := err == nil && statusCode >= 200 && statusCode < 300
errMsg := ""
if err != nil {
errMsg = err.Error()
}
e.recordResult(d, attemptNum, success, statusCode, respBody, errMsg, duration)
if success {
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
return
}
maxRetries := d.Target.MaxRetries
if maxRetries <= 0 {
maxRetries = 5 // default
}
if attemptNum >= maxRetries {
e.updateDeliveryStatus(d, database.DeliveryStatusFailed)
} else {
e.updateDeliveryStatus(d, database.DeliveryStatusRetrying)
}
}
func (e *Engine) deliverDatabase(d *database.Delivery) {
// The event is already stored in the database; mark as delivered.
e.recordResult(d, 1, true, 0, "", "", 0)
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
}
func (e *Engine) deliverLog(d *database.Delivery) {
e.log.Info("webhook event delivered to log target",
"delivery_id", d.ID,
"event_id", d.EventID,
"target_id", d.TargetID,
"target_name", d.Target.Name,
"method", d.Event.Method,
"content_type", d.Event.ContentType,
"body_length", len(d.Event.Body),
)
e.recordResult(d, 1, true, 0, "", "", 0)
e.updateDeliveryStatus(d, database.DeliveryStatusDelivered)
}
// doHTTPRequest performs the outbound HTTP POST to a target URL.
func (e *Engine) doHTTPRequest(cfg *HTTPTargetConfig, event *database.Event) (statusCode int, respBody string, durationMs int64, err error) {
start := time.Now()
req, err := http.NewRequest(http.MethodPost, cfg.URL, bytes.NewReader([]byte(event.Body)))
if err != nil {
return 0, "", 0, fmt.Errorf("creating request: %w", err)
}
// Set content type from original event
if event.ContentType != "" {
req.Header.Set("Content-Type", event.ContentType)
}
// Apply original headers (filtered)
var originalHeaders map[string][]string
if event.Headers != "" {
if jsonErr := json.Unmarshal([]byte(event.Headers), &originalHeaders); jsonErr == nil {
for k, vals := range originalHeaders {
if isForwardableHeader(k) {
for _, v := range vals {
req.Header.Add(k, v)
}
}
}
}
}
// Apply target-specific headers (override)
for k, v := range cfg.Headers {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", "webhooker/1.0")
client := e.client
if cfg.Timeout > 0 {
client = &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}
}
resp, err := client.Do(req)
durationMs = time.Since(start).Milliseconds()
if err != nil {
return 0, "", durationMs, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyLog))
if readErr != nil {
return resp.StatusCode, "", durationMs, fmt.Errorf("reading response body: %w", readErr)
}
return resp.StatusCode, string(body), durationMs, nil
}
func (e *Engine) recordResult(d *database.Delivery, attemptNum int, success bool, statusCode int, respBody, errMsg string, durationMs int64) {
result := &database.DeliveryResult{
DeliveryID: d.ID,
AttemptNum: attemptNum,
Success: success,
StatusCode: statusCode,
ResponseBody: truncate(respBody, maxBodyLog),
Error: errMsg,
Duration: durationMs,
}
if err := e.db.Create(result).Error; err != nil {
e.log.Error("failed to record delivery result",
"delivery_id", d.ID,
"error", err,
)
}
}
func (e *Engine) updateDeliveryStatus(d *database.Delivery, status database.DeliveryStatus) {
if err := e.db.Model(d).Update("status", status).Error; err != nil {
e.log.Error("failed to update delivery status",
"delivery_id", d.ID,
"status", status,
"error", err,
)
}
}
func (e *Engine) parseHTTPConfig(configJSON string) (*HTTPTargetConfig, error) {
if configJSON == "" {
return nil, fmt.Errorf("empty target config")
}
var cfg HTTPTargetConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return nil, fmt.Errorf("parsing config JSON: %w", err)
}
if cfg.URL == "" {
return nil, fmt.Errorf("target URL is required")
}
return &cfg, nil
}
// isForwardableHeader returns true if the header should be forwarded to targets.
// Hop-by-hop headers and internal headers are excluded.
func isForwardableHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case "Host", "Connection", "Keep-Alive", "Transfer-Encoding",
"Te", "Trailer", "Upgrade", "Proxy-Authorization",
"Proxy-Connection", "Content-Length":
return false
default:
return true
}
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}

View File

@ -56,6 +56,11 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
"index.html": parsePageTemplate("index.html"),
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html"),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
}
lc.Append(fx.Hook{

View File

@ -1,69 +1,520 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleSourceList shows a list of user's webhooks
// WebhookListItem holds data for the webhook list view.
type WebhookListItem struct {
database.Webhook
EntrypointCount int64
TargetCount int64
EventCount int64
}
// HandleSourceList shows a list of user's webhooks.
func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook list page
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
var webhooks []database.Webhook
if err := h.db.DB().Where("user_id = ?", userID).Order("created_at DESC").Find(&webhooks).Error; err != nil {
h.log.Error("failed to list webhooks", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Build list items with counts
items := make([]WebhookListItem, len(webhooks))
for i := range webhooks {
items[i].Webhook = webhooks[i]
h.db.DB().Model(&database.Entrypoint{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].EntrypointCount)
h.db.DB().Model(&database.Target{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].TargetCount)
h.db.DB().Model(&database.Event{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].EventCount)
}
data := map[string]interface{}{
"Webhooks": items,
}
h.renderTemplate(w, r, "sources_list.html", data)
}
}
// HandleSourceCreate shows the form to create a new webhook
// HandleSourceCreate shows the form to create a new webhook.
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook creation form
http.Error(w, "Not implemented", http.StatusNotImplemented)
data := map[string]interface{}{
"Error": "",
}
h.renderTemplate(w, r, "sources_new.html", data)
}
}
// HandleSourceCreateSubmit handles the webhook creation form submission
// HandleSourceCreateSubmit handles the webhook creation form submission.
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook creation logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
description := r.FormValue("description")
retentionStr := r.FormValue("retention_days")
if name == "" {
data := map[string]interface{}{
"Error": "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "sources_new.html", data)
return
}
retentionDays := 30
if retentionStr != "" {
if v, err := strconv.Atoi(retentionStr); err == nil && v > 0 {
retentionDays = v
}
}
// HandleSourceDetail shows details for a specific webhook
tx := h.db.DB().Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
webhook := &database.Webhook{
UserID: userID,
Name: name,
Description: description,
RetentionDays: retentionDays,
}
if err := tx.Create(webhook).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create webhook", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Auto-create one entrypoint
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: "Default entrypoint",
Active: true,
}
if err := tx.Create(entrypoint).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create entrypoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit transaction", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.log.Info("webhook created",
"webhook_id", webhook.ID,
"name", name,
"user_id", userID,
)
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleSourceDetail shows details for a specific webhook.
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook detail page
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
var entrypoints []database.Entrypoint
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&entrypoints)
var targets []database.Target
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&targets)
// Recent events with delivery info
var events []database.Event
h.db.DB().Where("webhook_id = ?", webhook.ID).Order("created_at DESC").Limit(20).Find(&events)
// Build host URL for display
host := r.Host
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
// Check X-Forwarded headers
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
scheme = fwdProto
}
data := map[string]interface{}{
"Webhook": webhook,
"Entrypoints": entrypoints,
"Targets": targets,
"Events": events,
"BaseURL": scheme + "://" + host,
}
h.renderTemplate(w, r, "source_detail.html", data)
}
}
// HandleSourceEdit shows the form to edit a webhook
// HandleSourceEdit shows the form to edit a webhook.
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook edit form
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
data := map[string]interface{}{
"Webhook": webhook,
"Error": "",
}
h.renderTemplate(w, r, "source_edit.html", data)
}
}
// HandleSourceEditSubmit handles the webhook edit form submission
// HandleSourceEditSubmit handles the webhook edit form submission.
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook update logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
data := map[string]interface{}{
"Webhook": webhook,
"Error": "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
webhook.Name = name
webhook.Description = r.FormValue("description")
if retStr := r.FormValue("retention_days"); retStr != "" {
if v, err := strconv.Atoi(retStr); err == nil && v > 0 {
webhook.RetentionDays = v
}
}
// HandleSourceDelete handles webhook deletion
if err := h.db.DB().Save(&webhook).Error; err != nil {
h.log.Error("failed to update webhook", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleSourceDelete handles webhook deletion (soft delete).
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook deletion logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
tx := h.db.DB().Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Soft-delete child records
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Entrypoint{})
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Target{})
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Event{})
tx.Delete(&webhook)
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit deletion", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.log.Info("webhook deleted", "webhook_id", webhook.ID, "user_id", userID)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
}
}
// HandleSourceLogs shows the request/response logs for a webhook
// HandleSourceLogs shows the request/response logs for a webhook.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement webhook logs page
http.Error(w, "Not implemented", http.StatusNotImplemented)
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Pagination
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
page = v
}
}
perPage := 25
offset := (page - 1) * perPage
var totalEvents int64
h.db.DB().Model(&database.Event{}).Where("webhook_id = ?", webhook.ID).Count(&totalEvents)
var events []database.Event
h.db.DB().Where("webhook_id = ?", webhook.ID).
Order("created_at DESC").
Offset(offset).
Limit(perPage).
Find(&events)
// Load deliveries for each event
type EventWithDeliveries struct {
database.Event
Deliveries []database.Delivery
}
eventsWithDeliveries := make([]EventWithDeliveries, len(events))
for i := range events {
eventsWithDeliveries[i].Event = events[i]
h.db.DB().Where("event_id = ?", events[i].ID).Preload("Target").Find(&eventsWithDeliveries[i].Deliveries)
}
totalPages := int(totalEvents) / perPage
if int(totalEvents)%perPage != 0 {
totalPages++
}
data := map[string]interface{}{
"Webhook": webhook,
"Events": eventsWithDeliveries,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": totalEvents,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.renderTemplate(w, r, "source_logs.html", data)
}
}
// HandleEntrypointCreate handles adding a new entrypoint to a webhook.
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
// Verify ownership
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
description := r.FormValue("description")
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: description,
Active: true,
}
if err := h.db.DB().Create(entrypoint).Error; err != nil {
h.log.Error("failed to create entrypoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleTargetCreate handles adding a new target to a webhook.
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
targetType := database.TargetType(r.FormValue("type"))
url := r.FormValue("url")
maxRetriesStr := r.FormValue("max_retries")
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
// Validate target type
switch targetType {
case database.TargetTypeHTTP, database.TargetTypeRetry, database.TargetTypeDatabase, database.TargetTypeLog:
// valid
default:
http.Error(w, "Invalid target type", http.StatusBadRequest)
return
}
// Build config JSON for HTTP-based targets
var configJSON string
if targetType == database.TargetTypeHTTP || targetType == database.TargetTypeRetry {
if url == "" {
http.Error(w, "URL is required for HTTP targets", http.StatusBadRequest)
return
}
cfg := map[string]interface{}{
"url": url,
}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
configJSON = string(configBytes)
}
maxRetries := 5
if maxRetriesStr != "" {
if v, err := strconv.Atoi(maxRetriesStr); err == nil && v > 0 {
maxRetries = v
}
}
target := &database.Target{
WebhookID: webhook.ID,
Name: name,
Type: targetType,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}
if err := h.db.DB().Create(target).Error; err != nil {
h.log.Error("failed to create target", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// getUserID extracts the user ID from the session.
func (h *Handlers) getUserID(r *http.Request) (string, bool) {
sess, err := h.session.Get(r)
if err != nil {
return "", false
}
if !h.session.IsAuthenticated(sess) {
return "", false
}
return h.session.GetUserID(sess)
}

View File

@ -1,41 +1,135 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleWebhook handles incoming webhook requests at entrypoint URLs
const (
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
maxWebhookBodySize = 1 << 20
)
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get entrypoint UUID from URL
entrypointUUID := chi.URLParam(r, "uuid")
if entrypointUUID == "" {
http.NotFound(w, r)
return
}
// Log the incoming webhook request
h.log.Info("webhook request received",
"entrypoint_uuid", entrypointUUID,
"method", r.Method,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
// Only POST methods are allowed for webhooks
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
// Look up entrypoint by path
var entrypoint database.Entrypoint
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint)
if result.Error != nil {
h.log.Debug("entrypoint not found", "path", entrypointUUID)
http.NotFound(w, r)
return
}
// TODO: Implement webhook handling logic
// Look up entrypoint by UUID, find parent webhook, fan out to targets
w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte("unimplemented"))
// Check if active
if !entrypoint.Active {
http.Error(w, "Gone", http.StatusGone)
return
}
// Read body with size limit
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil {
h.log.Error("failed to read request body", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if len(body) > maxWebhookBodySize {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
// Serialize headers as JSON
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.log.Error("failed to serialize headers", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create the event in a transaction
tx := h.db.DB().Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
event := &database.Event{
WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID,
Method: r.Method,
Headers: string(headersJSON),
Body: string(body),
ContentType: r.Header.Get("Content-Type"),
}
if err := tx.Create(event).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create event", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Find all active targets for this webhook
var targets []database.Target
if err := tx.Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; err != nil {
tx.Rollback()
h.log.Error("failed to query targets", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create delivery records for each active target
for i := range targets {
delivery := &database.Delivery{
EventID: event.ID,
TargetID: targets[i].ID,
Status: database.DeliveryStatusPending,
}
if err := tx.Create(delivery).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create delivery",
"target_id", targets[i].ID,
"error", err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit transaction", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.log.Info("webhook event created",
"event_id", event.ID,
"webhook_id", entrypoint.WebhookID,
"entrypoint_id", entrypoint.ID,
"target_count", len(targets),
)
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
h.log.Error("failed to write response", "error", err)
}
}

View File

@ -105,6 +105,8 @@ func (s *Server) SetupRoutes() {
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint
r.Post("/targets", s.h.HandleTargetCreate()) // Add target
})
// Entrypoint endpoint - accepts incoming webhook POST requests

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,154 @@
{{template "base" .}}
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="mb-6">
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2">
<div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}}
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
{{end}}
</div>
<div class="flex gap-2">
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<button type="submit" class="btn-danger">Delete</button>
</form>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints -->
<div class="card">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add
</button>
</div>
<!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
<button type="submit" class="btn-primary text-sm">Add</button>
</form>
</div>
<div class="divide-y divide-gray-100">
{{range .Entrypoints}}
<div class="p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
{{if .Active}}
<span class="badge-success">Active</span>
{{else}}
<span class="badge-error">Inactive</span>
{{end}}
</div>
<code class="text-xs text-gray-500 break-all block mt-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
{{end}}
</div>
</div>
<!-- Targets -->
<div class="card">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add
</button>
</div>
<!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32">
<option value="http">HTTP</option>
<option value="retry">Retry</option>
<option value="database">Database</option>
<option value="log">Log</option>
</select>
</div>
<div x-show="targetType === 'http' || targetType === 'retry'">
<input type="url" name="url" placeholder="https://example.com/webhook" class="input text-sm">
</div>
<div x-show="targetType === 'retry'" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" value="5" min="1" max="20" class="input text-sm w-24">
</div>
<button type="submit" class="btn-primary text-sm">Add Target</button>
</form>
</div>
<div class="divide-y divide-gray-100">
{{range .Targets}}
<div class="p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
<div class="flex items-center gap-2">
<span class="badge-info">{{.Type}}</span>
{{if .Active}}
<span class="badge-success">Active</span>
{{else}}
<span class="badge-error">Inactive</span>
{{end}}
</div>
</div>
{{if .Config}}
<code class="text-xs text-gray-500 break-all block mt-1">{{.Config}}</code>
{{end}}
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
{{end}}
</div>
</div>
</div>
<!-- Recent Events -->
<div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
</div>
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span>
</div>
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
</div>
</div>
{{else}}
<div class="p-8 text-center text-sm text-gray-500">No events received yet.</div>
{{end}}
</div>
</div>
<!-- Info -->
<div class="mt-4 text-sm text-gray-400">
<p>Retention: {{.Webhook.RetentionDays}} days &middot; Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
</div>
</div>
{{end}}

View File

@ -0,0 +1,40 @@
{{template "base" .}}
{{define "title"}}Edit {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
</div>
<div class="card p-6">
{{if .Error}}
<div class="alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
<div class="form-group">
<label for="name" class="label">Name</label>
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
</div>
<div class="form-group">
<label for="description" class="label">Description</label>
<textarea id="description" name="description" rows="3" class="input">{{.Webhook.Description}}</textarea>
</div>
<div class="form-group">
<label for="retention_days" class="label">Retention (days)</label>
<input type="number" id="retention_days" name="retention_days" value="{{.Webhook.RetentionDays}}" min="1" max="365" class="input">
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save Changes</button>
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}

View File

@ -0,0 +1,61 @@
{{template "base" .}}
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
</div>
</div>
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4" x-data="{ open: false }">
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
<div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span>
</div>
<div class="flex items-center gap-4">
{{range .Deliveries}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">
{{.Target.Name}}: {{.Status}}
</span>
{{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
</div>
</div>
{{else}}
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
{{end}}
</div>
</div>
<!-- Pagination -->
{{if or .HasPrev .HasNext}}
<div class="flex justify-center gap-2 mt-6">
{{if .HasPrev}}
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a>
{{end}}
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a>
{{end}}
</div>
{{end}}
</div>
{{end}}

View File

@ -0,0 +1,49 @@
{{template "base" .}}
{{define "title"}}Sources - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
<a href="/sources/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New Webhook
</a>
</div>
{{if .Webhooks}}
<div class="grid gap-4">
{{range .Webhooks}}
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
<div class="flex justify-between items-start">
<div>
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
{{if .Description}}
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
{{end}}
</div>
<span class="badge-info">{{.RetentionDays}}d retention</span>
</div>
<div class="flex gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
</div>
</a>
{{end}}
</div>
{{else}}
<div class="card p-12 text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
<a href="/sources/new" class="btn-primary">Create Webhook</a>
</div>
{{end}}
</div>
{{end}}

View File

@ -0,0 +1,41 @@
{{template "base" .}}
{{define "title"}}New Webhook - Webhooker{{end}}
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
</div>
<div class="card p-6">
{{if .Error}}
<div class="alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/sources/new" class="space-y-6">
<div class="form-group">
<label for="name" class="label">Name</label>
<input type="text" id="name" name="name" required autofocus placeholder="My Webhook" class="input">
</div>
<div class="form-group">
<label for="description" class="label">Description</label>
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input"></textarea>
</div>
<div class="form-group">
<label for="retention_days" class="label">Retention (days)</label>
<input type="number" id="retention_days" name="retention_days" value="30" min="1" max="365" class="input">
<p class="text-xs text-gray-500 mt-1">How long to keep event data.</p>
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Create Webhook</button>
<a href="/sources" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}