feat: implement core webhook engine, delivery system, and management UI (Phase 2)
All checks were successful
check / check (push) Successful in 1m49s
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:
parent
853f25ee67
commit
7f8469a0f2
131
README.md
131
README.md
@ -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
|
||||
|
||||
@ -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
383
internal/delivery/engine.go
Normal 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]
|
||||
}
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
154
templates/source_detail.html
Normal file
154
templates/source_detail.html
Normal 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">← 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 · Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
40
templates/source_edit.html
Normal file
40
templates/source_edit.html
Normal 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">← 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}}
|
||||
61
templates/source_logs.html
Normal file
61
templates/source_logs.html
Normal 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">← 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">← 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 →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
49
templates/sources_list.html
Normal file
49
templates/sources_list.html
Normal 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}}
|
||||
41
templates/sources_new.html
Normal file
41
templates/sources_new.html
Normal 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">← 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}}
|
||||
Loading…
Reference in New Issue
Block a user