diff --git a/README.md b/README.md index a088e35..3ca00aa 100644 --- a/README.md +++ b/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) | @@ -320,9 +315,9 @@ data for replay and auditing. | Field | Type | Description | | -------------- | ------ | ----------- | -| `id` | UUID | Primary key | -| `processor_id` | UUID | Foreign key → Webhook | -| `webhook_id` | UUID | Foreign key → Entrypoint | +| `id` | UUID | Primary key | +| `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 diff --git a/cmd/webhooker/main.go b/cmd/webhooker/main.go index 86612b7..f10b35c 100644 --- a/cmd/webhooker/main.go +++ b/cmd/webhooker/main.go @@ -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() } diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go new file mode 100644 index 0000000..81142de --- /dev/null +++ b/internal/delivery/engine.go @@ -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<= 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] +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2819730..269fd18 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -53,9 +53,14 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { // Parse all page templates once at startup s.templates = map[string]*template.Template{ - "index.html": parsePageTemplate("index.html"), - "login.html": parsePageTemplate("login.html"), - "profile.html": parsePageTemplate("profile.html"), + "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{ diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 11d166f..0c17039 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -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) +} diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index a515966..0912454 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -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) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index 4fbd340..457b570 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -100,11 +100,13 @@ func (s *Server) SetupRoutes() { s.router.Route("/source/{sourceID}", func(r chi.Router) { r.Use(s.mw.RequireAuth()) - r.Get("/", s.h.HandleSourceDetail()) // View webhook details - r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form - 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.Get("/", s.h.HandleSourceDetail()) // View webhook details + r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form + 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 diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 77b9048..b48cae1 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ -/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */ -@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-500:oklch(.637 .237 25.331);--color-red-800:oklch(.444 .177 26.899);--color-gray-50:oklch(.985 .002 247.839);--color-gray-100:oklch(.967 .003 264.542);--color-gray-200:oklch(.928 .006 264.531);--color-gray-300:oklch(.872 .01 258.338);--color-gray-500:oklch(.551 .027 264.364);--color-gray-600:oklch(.446 .03 256.802);--color-gray-700:oklch(.373 .034 259.733);--color-gray-900:oklch(.21 .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-light:300;--font-weight-medium:500;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings);--color-primary-50:#e3f2fd;--color-primary-100:#bbdefb;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-text{border-radius:var(--radius-md);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-600);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-text:hover{background-color:var(--color-primary-50)}}.btn-text:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-text:active{background-color:var(--color-primary-100)}.btn-text:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*3);color:var(--color-gray-900);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input::placeholder{color:var(--color-gray-500)}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing)*1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing)*4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing)*6);padding-block:calc(var(--spacing)*4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:color-mix(in oklab,var(--color-error-500)20%,transparent);background-color:var(--color-error-50);padding:calc(var(--spacing)*4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:color-mix(in oklab,var(--color-success-500)20%,transparent);background-color:var(--color-success-50);padding:calc(var(--spacing)*4);color:var(--color-success-700)}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.static{position:static}.mx-1{margin-inline:calc(var(--spacing)*1)}.mx-3{margin-inline:calc(var(--spacing)*3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-4{margin-right:calc(var(--spacing)*4)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-10{margin-bottom:calc(var(--spacing)*10)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-16{height:calc(var(--spacing)*16)}.min-h-screen{min-height:100vh}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-16{width:calc(var(--spacing)*16)}.w-32{width:calc(var(--spacing)*32)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:var(--tw-rotate-x)var(--tw-rotate-y)var(--tw-rotate-z)var(--tw-skew-x)var(--tw-skew-y)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-success-50{background-color:var(--color-success-50)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-3{padding-block:calc(var(--spacing)*3)}.py-6{padding-block:calc(var(--spacing)*6)}.py-12{padding-block:calc(var(--spacing)*12)}.pt-4{padding-top:calc(var(--spacing)*4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-primary-500{color:var(--color-primary-500)}.text-success-500{color:var(--color-success-500)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-primary-600:hover{color:var(--color-primary-600)}}@media (width>=48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false;initial-value:rotateX(0)}@property --tw-rotate-y{syntax:"*";inherits:false;initial-value:rotateY(0)}@property --tw-rotate-z{syntax:"*";inherits:false;initial-value:rotateZ(0)}@property --tw-skew-x{syntax:"*";inherits:false;initial-value:skewX(0)}@property --tw-skew-y{syntax:"*";inherits:false;initial-value:skewY(0)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-light:300;--font-weight-medium:500;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-100:#bbdefb;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-text{border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-600);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-text:hover{background-color:var(--color-primary-50)}}.btn-text:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-text:active{background-color:var(--color-primary-100)}.btn-text:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}.overflow-x-auto{overflow-x:auto}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-success-50{background-color:var(--color-success-50)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-primary-600{color:var(--color-primary-600)}.text-red-600{color:var(--color-red-600)}.text-success-500{color:var(--color-success-500)}.text-yellow-600{color:var(--color-yellow-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-primary-600:hover{color:var(--color-primary-600)}.hover\:text-primary-700:hover{color:var(--color-primary-700)}}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/templates/source_detail.html b/templates/source_detail.html new file mode 100644 index 0000000..8a5b65e --- /dev/null +++ b/templates/source_detail.html @@ -0,0 +1,154 @@ +{{template "base" .}} + +{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to webhooks +
+
+

{{.Webhook.Name}}

+ {{if .Webhook.Description}} +

{{.Webhook.Description}}

+ {{end}} +
+
+ Event Log + Edit +
+ +
+
+
+
+ +
+ +
+
+

Entrypoints

+ +
+ + +
+
+ + +
+
+ +
+ {{range .Entrypoints}} +
+
+ {{if .Description}}{{.Description}}{{else}}Entrypoint{{end}} + {{if .Active}} + Active + {{else}} + Inactive + {{end}} +
+ {{$.BaseURL}}/webhook/{{.Path}} +
+ {{else}} +
No entrypoints configured.
+ {{end}} +
+
+ + +
+
+

Targets

+ +
+ + +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+ {{range .Targets}} +
+
+ {{.Name}} +
+ {{.Type}} + {{if .Active}} + Active + {{else}} + Inactive + {{end}} +
+
+ {{if .Config}} + {{.Config}} + {{end}} +
+ {{else}} +
No targets configured.
+ {{end}} +
+
+
+ + +
+
+

Recent Events

+ View All +
+
+ {{range .Events}} +
+
+
+ {{.Method}} + {{.ContentType}} +
+ {{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}} +
+
+ {{else}} +
No events received yet.
+ {{end}} +
+
+ + +
+

Retention: {{.Webhook.RetentionDays}} days · Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}

+
+
+{{end}} diff --git a/templates/source_edit.html b/templates/source_edit.html new file mode 100644 index 0000000..365146a --- /dev/null +++ b/templates/source_edit.html @@ -0,0 +1,40 @@ +{{template "base" .}} + +{{define "title"}}Edit {{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to {{.Webhook.Name}} +

Edit Webhook

+
+ +
+ {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+{{end}} diff --git a/templates/source_logs.html b/templates/source_logs.html new file mode 100644 index 0000000..2217466 --- /dev/null +++ b/templates/source_logs.html @@ -0,0 +1,61 @@ +{{template "base" .}} + +{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to {{.Webhook.Name}} +
+

Event Log

+ {{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}} +
+
+ +
+
+ {{range .Events}} +
+
+
+ {{.Method}} + {{.ID}} + {{.ContentType}} +
+
+ {{range .Deliveries}} + + {{.Target.Name}}: {{.Status}} + + {{end}} + {{.CreatedAt.Format "2006-01-02 15:04:05"}} + + + +
+
+ +
+
{{.Body}}
+
+
+ {{else}} +
No events recorded yet.
+ {{end}} +
+
+ + + {{if or .HasPrev .HasNext}} +
+ {{if .HasPrev}} + ← Previous + {{end}} + Page {{.Page}} of {{.TotalPages}} + {{if .HasNext}} + Next → + {{end}} +
+ {{end}} +
+{{end}} diff --git a/templates/sources_list.html b/templates/sources_list.html new file mode 100644 index 0000000..8d9a20c --- /dev/null +++ b/templates/sources_list.html @@ -0,0 +1,49 @@ +{{template "base" .}} + +{{define "title"}}Sources - Webhooker{{end}} + +{{define "content"}} + +{{end}} diff --git a/templates/sources_new.html b/templates/sources_new.html new file mode 100644 index 0000000..321ce8f --- /dev/null +++ b/templates/sources_new.html @@ -0,0 +1,41 @@ +{{template "base" .}} + +{{define "title"}}New Webhook - Webhooker{{end}} + +{{define "content"}} +
+
+ ← Back to webhooks +

Create Webhook

+
+ +
+ {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +

How long to keep event data.

+
+ +
+ + Cancel +
+
+
+
+{{end}}