feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s

Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).

Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
  backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged

Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
  apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated

Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
  the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
2026-05-16 02:24:31 +03:00
parent 30133bc1eb
commit 2aff22f565
21 changed files with 7445 additions and 460 deletions
+121 -69
View File
@@ -7,11 +7,27 @@ test coverage on triggers / image helpers / webhook parser / store upserts are
**already landed and live**. What follows is what's still pending, in priority **already landed and live**. What follows is what's still pending, in priority
order. order.
> ## Current focus (read this first)
>
> **Triggers as first-class reusable entities — DONE** (2026-05-16). The
> trigger-split arc shipped end-to-end: `triggers` + `workload_trigger_bindings`
> tables, boot-time backfill, fan-out webhook handler at
> `/api/webhook/triggers/{secret}` with bounded concurrency, `/api/triggers`
> CRUD + `/api/bindings/{id}` + workload-side bind endpoints, full `/triggers`
> frontend (list, new, detail), workload-page bindings panel + per-binding
> override editor, i18n EN+RU.
>
> **Next on Priority 1** is the **static source inline port** (~2150 LOC
> across 8 files; details in the section below). After that, the
> **hard legacy cutover** (drop `/api/projects`, `/api/stacks`, `/api/sites`,
> `/api/stages` + their tables and frontends) clears the deck.
## Status at a glance ## Status at a glance
| Item | Priority | Status | | Item | Priority | Status |
| ---- | -------- | ------ | | ---- | -------- | ------ |
| Static source inline port | 1 | **PENDING** — only remaining blocker for hard cutover | | Triggers as first-class reusable entities | 1 | **DONE** (2026-05-16) |
| Static source inline port | 1 | **PENDING — current focus** |
| Hard legacy cutover | 1 | **PENDING** — gated by static port (volume scopes blocker is resolved) | | Hard legacy cutover | 1 | **PENDING** — gated by static port (volume scopes blocker is resolved) |
| Generalized volume scopes | 2 | DONE | | Generalized volume scopes | 2 | DONE |
| Kind-aware editors (compose / image / static) | 2 | DONE | | Kind-aware editors (compose / image / static) | 2 | DONE |
@@ -21,7 +37,6 @@ order.
| i18n for `/apps/*` page strings | 3 | **PARTIAL** — Log Rules panel + Observability surfaces i18n'd; `apps.*` namespace still pending | | i18n for `/apps/*` page strings | 3 | **PARTIAL** — Log Rules panel + Observability surfaces i18n'd; `apps.*` namespace still pending |
| Docs / codemap entries for `internal/workload/plugin/` | 3 | **PENDING** | | Docs / codemap entries for `internal/workload/plugin/` | 3 | **PENDING** |
| API-handler / dispatcher / compose-source / static-backend tests | 4 | **PENDING** | | API-handler / dispatcher / compose-source / static-backend tests | 4 | **PENDING** |
| Triggers as first-class reusable entities (post-cutover) | 5 | **PENDING** |
Cross-references to the adjacent Observability work (Event Triggers + Log Cross-references to the adjacent Observability work (Event Triggers + Log
Scanner backend + drop-counter stats panel) live in Scanner backend + drop-counter stats panel) live in
@@ -29,6 +44,110 @@ Scanner backend + drop-counter stats panel) live in
## Priority 1 — Architecture unlock ## Priority 1 — Architecture unlock
### ~~Triggers as first-class reusable entities~~ — DONE (2026-05-16)
Trigger config used to live embedded in the workload row
(`workload.trigger_kind` + `workload.trigger_config`). One workload owned
exactly one trigger; one trigger served exactly one workload. The split
makes a Trigger its own record so one inbound webhook / registry watcher /
schedule / git-push filter fans out to many workloads.
**Schema + store**`triggers` + `workload_trigger_bindings` tables with
`ON DELETE CASCADE`. `binding_config` JSON merges on top of `trigger.config`
(top-level merge, binding wins). Boot-time backfill lifts every existing
embedded trigger into a standalone trigger row + binding inside a
per-workload transaction so a partial failure rolls back cleanly. Trigger
names are id-suffixed unconditionally to dodge the (name, kind) collision
race. `store.ErrUnique` sentinel translates SQLite UNIQUE violations at
the store boundary; API handlers use `errors.Is` instead of substring
match. `MergeJSONConfig` always returns a freshly allocated slice (no
aliasing under fan-out).
**Webhook fan-out** — new `POST /api/webhook/triggers/{secret}` resolves
to one Trigger and fans out to every enabled binding via a bounded worker
pool (`maxTriggerFanOutConcurrency = 4`). Per-binding errors are isolated
(one broken workload doesn't block siblings). Outcome accounting splits
deployed / skipped / no-match / errored cleanly. Legacy
`POST /api/webhook/workloads/{secret}` route dropped (clean break per the
workload-first memory; the boot backfill kept secrets resolvable at the
new path).
**API**`/api/triggers` CRUD, `/api/triggers/{id}/webhook`,
`/api/triggers/{id}/bindings` (list + bind), `/api/bindings/{id}` for
update and delete, and `/api/workloads/{id}/triggers` (list + bind,
accepts either `trigger_id` or inline `{kind, name, config, ...}`).
Inline-create path
runs trigger insert + binding insert inside one transaction
(`CreateTriggerWithBindingTx`) so a binding failure can't leak an orphan
trigger. `validateBindingConfig` enforces 8 KiB cap and runs the trigger
plugin's `Validate()` against the merged shape on every bind/update.
List endpoints use `LEFT JOIN ... GROUP BY` (`ListTriggersWithBindingCount`,
`ListBindingsForTriggerWithNames`, `ListBindingsForWorkloadWithNames`) —
no per-row N+1.
**Plugin contract unchanged**`Trigger.Match` still takes `(Workload,
InboundEvent)`. The fan-out path uses `plugin.WithEffectiveTrigger` to
stuff the merged config into a copied workload before the call, so the
existing `registry`, `git`, `manual` plugins work unchanged.
**Reconciler** — gate dropped from `(SourceKind != "" && TriggerKind != "")`
to `SourceKind != ""`. A workload with a Source but no triggers still
gets `Source.Reconcile` called every tick (manual-only deploys are
common during early setup).
**Frontend** — new pages under `web/src/routes/triggers/`:
- `+page.svelte` — list with kind chips, binding count, webhook status,
empty state.
- `new/+page.svelte` — wizard with kind picker (cards), name, kind-aware
config form (registry / git / manual + JSON fallback), webhook toggles.
- `[id]/+page.svelte` — editable per-kind form, webhook URL panel
(origin-prefixed, copy + ConfirmDialog-gated rotate), bindings list
with per-row enabled `<ToggleSwitch>` + ConfirmDialog-gated unbind,
danger-zone delete.
**Workload UI** — embedded trigger fields removed.
- `apps/new/+page.svelte` — wizard now has Trigger step with NEW / PICK /
SKIP modes; bind happens after `createPluginWorkload` succeeds.
- `apps/[id]/+page.svelte` — Bindings panel above Containers, "Add trigger"
modal with Inline / Pick-existing tabs, **per-binding override editor**
(inline disclosure with read-only base config, editable JSON override,
merged preview, 8 KiB byte cap, save / reset-to-inherit). Per-row
"OVERRIDES n FIELDS" badge surfaces deviation from the trigger.
**Shared component**`web/src/lib/components/TriggerKindForm.svelte`
hosts the kind picker + name + per-kind config + JSON fallback + webhook
toggles. Reused on both `/triggers/new` and the workload Add-trigger modal.
**i18n** — full EN + RU coverage under `redeployTriggers.*` (standalone
pages), `apps.detail.bindings.*` (workload bindings panel including
`override.*`), `apps.new.triggers.*` (wizard mode picker), `nav.triggers`.
The existing `/event-triggers` nav label was disambiguated to "Event
Triggers" to coexist with the new `/triggers` entry.
**Compliance** — three pre-existing raw `<input type="checkbox">`
instances in `apps/new` + `apps/[id]` (render-markdown, env-encrypted)
replaced with `<ToggleSwitch>` to honor the project rule.
**Touch points (final):**
- `internal/store/triggers.go`, `workload_trigger_bindings.go`, `models.go`,
`store.go` (schema + backfill + `translateSQLError`).
- `internal/workload/plugin/binding.go` (`MergeJSONConfig`,
`WithEffectiveTrigger`).
- `internal/webhook/trigger_handler.go` + `handler.go` (route mount,
legacy route removed).
- `internal/reconciler/reconciler.go` (trigger gate dropped).
- `internal/api/triggers.go` + `router.go` (REST surface).
- `web/src/routes/triggers/`, `web/src/routes/apps/{new,[id]}`,
`web/src/lib/components/TriggerKindForm.svelte`, `web/src/lib/api.ts`,
`web/src/lib/i18n/{en,ru}.json`, `web/src/routes/+layout.svelte`.
**Reviews shipped through go-reviewer + security-reviewer +
typescript-reviewer subagents** — 0 CRITICAL; 5 HIGH and 4 MEDIUM
findings addressed inline before merge.
### Static source inline port — ~2150 LOC across 8 files ### Static source inline port — ~2150 LOC across 8 files
The current `internal/workload/plugin/source/static/` delegates to The current `internal/workload/plugin/source/static/` delegates to
@@ -228,73 +347,6 @@ Solid pure-function coverage landed in the prior turn. Still missing:
short-circuit. (Both pure; just need fixtures.) short-circuit. (Both pure; just need fixtures.)
- **Static source Backend adapter** in `cmd/server/static_backend.go`. - **Static source Backend adapter** in `cmd/server/static_backend.go`.
## Priority 5 — Post-cutover roadmap
### Triggers as first-class reusable entities
Today a trigger's config lives embedded in the workload row
(`workload.trigger_kind` plus `workload.trigger_config` JSON via the plugin
contract). One workload owns exactly one trigger; one trigger serves exactly
one workload. This couples two concepts that users increasingly want
orthogonal:
- One **inbound webhook** fanning out to several workloads (a single CI push
rebuilds dev + staging together).
- One **registry watcher** driving multiple workloads off the same image
(different tag filters per binding, shared poll state).
- One **schedule** kicking off a batch of jobs.
- One **git push** filter shared by sibling stack services.
**Direction:** promote triggers to their own table with a join.
- `triggers``id`, `kind` (registry / git / webhook / schedule / manual /
log_scan), `config` JSON, `secret`, `created_at`, audit fields.
- `workload_trigger_bindings``workload_id`, `trigger_id`, `binding_config`
JSON (per-binding overrides: tag filter, path filter, branch filter), plus
ordering / enabled flag.
The dispatcher seam stays unchanged — `deployer.DispatchPlugin` still receives
a `(Workload, TriggerEvent)` pair; the only change is that the event's source
is resolved through the binding row instead of the workload row.
**UX principle: first-class on the backend, inline by default in the UI.**
The workload create/edit form still has an "Add trigger" control that creates
a fresh trigger record in one step, so the 1:1 case (git push → this workload)
feels unchanged from today. Reuse is **opt-in** via a "Pick existing trigger"
picker on the same control. Triggers also get their own list/detail pages under
`/triggers` so the fan-out cases are discoverable and centrally manageable
(rotate secret once, audit once).
**Per-kind modal applies, same rule as Source plugins** — the create/edit
form for a trigger switches body by `kind` (git: repo / branch / path;
registry: image / tag regex; webhook: secret + payload preview; schedule:
cron). Backend cheap, UI requires a paired hand-rolled form per kind. Treat
"ship the kind-aware form" as part of done for any new trigger kind.
**Migration:** clean break (no migration) per the workload-first memory —
at cutover, each workload's embedded trigger config becomes a single
auto-created trigger record with a single binding row. No user-visible change
on day one; reuse becomes possible thereafter.
**Sequencing:** lands **after** the Priority 1 hard cutover. The embedded
trigger config works fine for the 1:1 case that dominates today; the
static-source inline port is the higher-value blocker. Treat this as the
next major arc once cutover ships.
**Touch points to expect:**
- `internal/workload/plugin/trigger/*` — kind handlers stay; only their input
shape changes (read from binding + trigger row, not workload row).
- `internal/store/` — new `triggers` + `workload_trigger_bindings` tables and
CRUD; remove `trigger_kind` / `trigger_config` from the workload row.
- `internal/api/workloads.go` — adapt the workload create/edit handlers to
accept either "inline new trigger" or "bind existing trigger" payloads.
- New `/api/triggers` surface + `/triggers` frontend pages.
- `internal/webhook/handler.go` — inbound webhook now resolves to a trigger,
fans out to all bound workloads.
- `internal/reconciler/reconciler.go` — registry watchers iterate triggers,
not workloads; each trigger may fire N bindings.
## Open architectural questions ## Open architectural questions
### Stages chain vs explicit Stage entity ### Stages chain vs explicit Stage entity
+26
View File
@@ -430,6 +430,12 @@ func (s *Server) Router() chi.Router {
// running image tag onto this workload's default_tag. // running image tag onto this workload's default_tag.
r.Get("/chain", s.getWorkloadChain) r.Get("/chain", s.getWorkloadChain)
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload) r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
// Trigger bindings on this workload — the symmetric view
// of /triggers/{id}/bindings keyed on the workload side
// so the workload detail page is one round-trip.
r.Get("/triggers", s.listBindingsForWorkload)
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
}) })
// Global container index, joined to workload + app names. // Global container index, joined to workload + app names.
@@ -446,6 +452,26 @@ func (s *Server) Router() chi.Router {
r.Delete("/apps/{id}", s.deleteApp) r.Delete("/apps/{id}", s.deleteApp)
}) })
// First-class Triggers (redeploy signal sources). One trigger
// (registry / git / webhook / manual / schedule / log_scan)
// fans out to many workloads via workload_trigger_bindings.
// Reads are open to authenticated users; mutations + secret
// rotation are admin-gated.
r.Get("/triggers", s.listTriggers)
r.Get("/triggers/{id}", s.getTrigger)
r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger)
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
r.Post("/triggers", s.createTrigger)
r.Put("/triggers/{id}", s.updateTrigger)
r.Delete("/triggers/{id}", s.deleteTrigger)
r.Get("/triggers/{id}/webhook", s.getTriggerWebhook)
r.Post("/triggers/{id}/webhook/regenerate", s.regenerateTriggerWebhook)
r.Post("/triggers/{id}/bindings", s.bindWorkloadToTrigger)
r.Put("/bindings/{bid}", s.updateBinding)
r.Delete("/bindings/{bid}", s.deleteBinding)
})
// Event triggers: filter+action rules over the event_log // Event triggers: filter+action rules over the event_log
// stream. Read endpoints are available to any authenticated // stream. Read endpoints are available to any authenticated
// user; mutations + test-dispatch are admin-gated since they // user; mutations + test-dispatch are admin-gated since they
+628
View File
@@ -0,0 +1,628 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// triggerView is the response shape for /api/triggers. Webhook secrets
// are never serialized — read them via the dedicated /webhook subresource
// where the canonical URL is composed.
type triggerView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
WebhookEnabled bool `json:"webhook_enabled"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
BindingCount int `json:"binding_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Server) toTriggerView(t store.Trigger) triggerView {
count, err := s.store.CountBindingsForTrigger(t.ID)
if err != nil {
slog.Warn("triggerView: count bindings", "trigger", t.ID, "error", err)
}
return triggerView{
ID: t.ID,
Kind: t.Kind,
Name: t.Name,
Config: json.RawMessage(t.Config),
WebhookEnabled: t.WebhookSecret != "",
WebhookRequireSignature: t.WebhookRequireSignature,
BindingCount: count,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
}
// toTriggerViewWithCount is the join-aware variant used by listTriggers
// to avoid one COUNT(*) per row. Kept distinct from toTriggerView so
// single-row paths (get/create/update) keep the simple call shape.
func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView {
return triggerView{
ID: row.ID,
Kind: row.Kind,
Name: row.Name,
Config: json.RawMessage(row.Config),
WebhookEnabled: row.WebhookSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
BindingCount: row.BindingCount,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
// triggerRequest is the create/update body. Config is opaque per kind.
// Auto-generates a webhook secret on create when WebhookEnabled is true;
// the secret is exposed only via the /webhook subresource.
type triggerRequest struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
WebhookEnabled bool `json:"webhook_enabled"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
// Same per-blob caps used on the workload pluginWorkloadRequest path —
// triggers and workload trigger configs share the same plugin Validate()
// call, so the byte budget should match.
const maxTriggerStandaloneConfigBytes = 16 << 10
func (s *Server) listTriggers(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("kind")
rows, err := s.store.ListTriggersWithBindingCount(kind)
if err != nil {
slog.Error("list triggers", "error", err)
respondError(w, http.StatusInternalServerError, "list triggers")
return
}
out := make([]triggerView, 0, len(rows))
for _, t := range rows {
out = append(out, toTriggerViewWithCount(t))
}
respondJSON(w, http.StatusOK, out)
}
func (s *Server) getTrigger(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
t, err := s.store.GetTriggerByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "get trigger")
return
}
respondJSON(w, http.StatusOK, s.toTriggerView(t))
}
// buildTriggerFromRequest assembles a store.Trigger ready for insert.
// Centralized so the standalone create endpoint and the inline-bind
// endpoint cannot drift on secret-generation defaults.
func buildTriggerFromRequest(req triggerRequest) store.Trigger {
t := store.Trigger{
Kind: req.Kind,
Name: strings.TrimSpace(req.Name),
Config: string(req.Config),
WebhookRequireSignature: req.WebhookRequireSignature,
}
if req.WebhookEnabled {
t.WebhookSecret = generateWebhookSecret()
t.WebhookSigningSecret = generateWebhookSecret()
}
return t
}
func (s *Server) createTrigger(w http.ResponseWriter, r *http.Request) {
var req triggerRequest
if !decodeJSONStrict(w, r, &req) {
return
}
if err := validateTriggerRequest(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
created, err := s.store.CreateTrigger(buildTriggerFromRequest(req))
if err != nil {
slog.Error("create trigger", "error", err)
// UNIQUE name collision is the most common user-facing failure.
if errors.Is(err, store.ErrUnique) {
respondError(w, http.StatusConflict, "a trigger with this name already exists")
return
}
respondError(w, http.StatusInternalServerError, "create trigger")
return
}
respondJSON(w, http.StatusCreated, s.toTriggerView(created))
}
func (s *Server) updateTrigger(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetTriggerByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "get trigger")
return
}
var req triggerRequest
if !decodeJSONStrict(w, r, &req) {
return
}
// Kind is immutable on update. Mirror the value from the existing
// row so validateTriggerRequest can still verify the config blob.
req.Kind = existing.Kind
if err := validateTriggerRequest(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if req.Name != "" {
existing.Name = strings.TrimSpace(req.Name)
}
if len(req.Config) > 0 {
existing.Config = string(req.Config)
}
existing.WebhookRequireSignature = req.WebhookRequireSignature
wasEnabled := existing.WebhookSecret != ""
if req.WebhookEnabled && !wasEnabled {
// false→true transition: rotate both secrets so re-enabling
// after a disable does not silently revive an old leaked URL.
existing.WebhookSecret = generateWebhookSecret()
existing.WebhookSigningSecret = generateWebhookSecret()
}
if !req.WebhookEnabled {
existing.WebhookSecret = ""
existing.WebhookSigningSecret = ""
}
if err := s.store.UpdateTrigger(existing); err != nil {
slog.Error("update trigger", "error", err)
if errors.Is(err, store.ErrUnique) {
respondError(w, http.StatusConflict, "a trigger with this name already exists")
return
}
respondError(w, http.StatusInternalServerError, "update trigger")
return
}
respondJSON(w, http.StatusOK, s.toTriggerView(existing))
}
func (s *Server) deleteTrigger(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.DeleteTrigger(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "delete trigger")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// triggerWebhookView surfaces the inbound URL for a trigger. Returns
// empty path / secret when the trigger has webhook ingress disabled.
type triggerWebhookView struct {
URL string `json:"url"`
Secret string `json:"secret"`
WebhookRequireSignature bool `json:"webhook_require_signature"`
}
func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
t, err := s.store.GetTriggerByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "get trigger")
return
}
view := triggerWebhookView{
Secret: t.WebhookSecret,
WebhookRequireSignature: t.WebhookRequireSignature,
}
if t.WebhookSecret != "" {
view.URL = "/api/webhook/triggers/" + t.WebhookSecret
}
respondJSON(w, http.StatusOK, view)
}
func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret := generateWebhookSecret()
if err := s.store.SetTriggerWebhookSecret(id, secret); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "rotate webhook secret")
return
}
respondJSON(w, http.StatusOK, map[string]string{
"secret": secret,
"url": "/api/webhook/triggers/" + secret,
})
}
// maxBindingConfigBytes caps a per-binding override blob. Smaller than
// the full trigger config — bindings should be lightweight tweaks
// (tag pattern, branch filter), not whole replacement configs.
const maxBindingConfigBytes = 8 << 10
// validateBindingConfig enforces the size cap and runs the trigger
// plugin's Validate() against the merged (trigger.config + binding)
// shape so a malformed override is caught at write time instead of
// silently breaking webhook fan-out at deploy time.
func validateBindingConfig(trg store.Trigger, bindingConfig json.RawMessage) error {
if len(bindingConfig) > maxBindingConfigBytes {
return fmt.Errorf("binding_config exceeds %d bytes", maxBindingConfigBytes)
}
merged, err := plugin.MergeJSONConfig(json.RawMessage(trg.Config), bindingConfig)
if err != nil {
return fmt.Errorf("binding_config: %w", err)
}
tp, err := plugin.GetTrigger(trg.Kind)
if err != nil {
return err
}
return tp.Validate(merged)
}
// validateTriggerRequest type-checks the trigger via the registered
// plugin. Accepts an empty config only when the plugin allows it (e.g.
// the manual trigger).
func validateTriggerRequest(req triggerRequest) error {
if strings.TrimSpace(req.Kind) == "" {
return fmt.Errorf("kind is required")
}
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if len(req.Config) > maxTriggerStandaloneConfigBytes {
return fmt.Errorf("config exceeds %d bytes", maxTriggerStandaloneConfigBytes)
}
tp, err := plugin.GetTrigger(req.Kind)
if err != nil {
return err
}
return tp.Validate(req.Config)
}
// bindingView shapes one binding for the /api/triggers/{id}/bindings
// listing. Includes the workload's name to avoid an N+1 round-trip on
// the frontend.
type bindingView struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
WorkloadName string `json:"workload_name"`
TriggerID string `json:"trigger_id"`
BindingConfig json.RawMessage `json:"binding_config"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Server) toBindingView(b store.WorkloadTriggerBinding) bindingView {
name := ""
if w, err := s.store.GetWorkloadByID(b.WorkloadID); err == nil {
name = w.Name
}
return bindingView{
ID: b.ID,
WorkloadID: b.WorkloadID,
WorkloadName: name,
TriggerID: b.TriggerID,
BindingConfig: json.RawMessage(b.BindingConfig),
Enabled: b.Enabled,
SortOrder: b.SortOrder,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
}
}
func (s *Server) listBindingsForTrigger(w http.ResponseWriter, r *http.Request) {
tid := chi.URLParam(r, "id")
if _, err := s.store.GetTriggerByID(tid); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "get trigger")
return
}
rows, err := s.store.ListBindingsForTriggerWithNames(tid)
if err != nil {
respondError(w, http.StatusInternalServerError, "list bindings")
return
}
out := make([]bindingView, 0, len(rows))
for _, b := range rows {
out = append(out, bindingView{
ID: b.ID,
WorkloadID: b.WorkloadID,
WorkloadName: b.WorkloadName,
TriggerID: b.TriggerID,
BindingConfig: json.RawMessage(b.BindingConfig),
Enabled: b.Enabled,
SortOrder: b.SortOrder,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
})
}
respondJSON(w, http.StatusOK, out)
}
// bindingRequest is shared by trigger-side bind (POST .../bindings) and
// workload-side bind (POST workloads/{id}/triggers).
type bindingRequest struct {
WorkloadID string `json:"workload_id"`
TriggerID string `json:"trigger_id"`
BindingConfig json.RawMessage `json:"binding_config"`
Enabled *bool `json:"enabled"`
SortOrder int `json:"sort_order"`
}
func (s *Server) bindWorkloadToTrigger(w http.ResponseWriter, r *http.Request) {
tid := chi.URLParam(r, "id")
var req bindingRequest
if !decodeJSONStrict(w, r, &req) {
return
}
if req.WorkloadID == "" {
respondError(w, http.StatusBadRequest, "workload_id is required")
return
}
trg, err := s.store.GetTriggerByID(tid)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "get trigger")
return
}
if _, err := s.store.GetWorkloadByID(req.WorkloadID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
b := store.WorkloadTriggerBinding{
WorkloadID: req.WorkloadID,
TriggerID: tid,
BindingConfig: string(req.BindingConfig),
Enabled: enabled,
SortOrder: req.SortOrder,
}
created, err := s.store.CreateBinding(b)
if err != nil {
if errors.Is(err, store.ErrUnique) {
respondError(w, http.StatusConflict, "this workload is already bound to this trigger")
return
}
slog.Error("create binding", "error", err)
respondError(w, http.StatusInternalServerError, "create binding")
return
}
respondJSON(w, http.StatusCreated, s.toBindingView(created))
}
func (s *Server) updateBinding(w http.ResponseWriter, r *http.Request) {
bid := chi.URLParam(r, "bid")
existing, err := s.store.GetBindingByID(bid)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "binding")
return
}
respondError(w, http.StatusInternalServerError, "get binding")
return
}
var req bindingRequest
if !decodeJSONStrict(w, r, &req) {
return
}
if len(req.BindingConfig) > 0 {
trg, terr := s.store.GetTriggerByID(existing.TriggerID)
if terr != nil {
slog.Error("update binding: trigger lookup", "trigger", existing.TriggerID, "error", terr)
respondError(w, http.StatusInternalServerError, "trigger lookup")
return
}
if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
existing.BindingConfig = string(req.BindingConfig)
}
if req.Enabled != nil {
existing.Enabled = *req.Enabled
}
existing.SortOrder = req.SortOrder
if err := s.store.UpdateBinding(existing); err != nil {
respondError(w, http.StatusInternalServerError, "update binding")
return
}
respondJSON(w, http.StatusOK, s.toBindingView(existing))
}
// listBindingsForWorkload is the workload-side mirror of
// listBindingsForTrigger. Returns every trigger bound to the workload
// in sort_order so the detail page can render them inline.
func (s *Server) listBindingsForWorkload(w http.ResponseWriter, r *http.Request) {
wid := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(wid); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
rows, err := s.store.ListBindingsForWorkloadWithNames(wid)
if err != nil {
respondError(w, http.StatusInternalServerError, "list bindings")
return
}
type item struct {
bindingView
TriggerKind string `json:"trigger_kind"`
TriggerName string `json:"trigger_name"`
}
out := make([]item, 0, len(rows))
for _, b := range rows {
out = append(out, item{
bindingView: bindingView{
ID: b.ID,
WorkloadID: b.WorkloadID,
TriggerID: b.TriggerID,
BindingConfig: json.RawMessage(b.BindingConfig),
Enabled: b.Enabled,
SortOrder: b.SortOrder,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
},
TriggerKind: b.TriggerKind,
TriggerName: b.TriggerName,
})
}
respondJSON(w, http.StatusOK, out)
}
// workloadBindRequest covers the two UX flows: bind an existing trigger
// (TriggerID present) or inline-create one in the same call (TriggerID
// empty + Inline populated). The inline form keeps the 1:1 case feeling
// unchanged from the embedded-trigger era.
type workloadBindRequest struct {
TriggerID string `json:"trigger_id"`
BindingConfig json.RawMessage `json:"binding_config"`
Enabled *bool `json:"enabled"`
SortOrder int `json:"sort_order"`
Inline *triggerRequest `json:"inline"`
}
func (s *Server) bindTriggerToWorkload(w http.ResponseWriter, r *http.Request) {
wid := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(wid); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
var req workloadBindRequest
if !decodeJSONStrict(w, r, &req) {
return
}
if req.TriggerID == "" && req.Inline == nil {
respondError(w, http.StatusBadRequest, "either trigger_id or inline trigger is required")
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
// Inline path: create trigger + binding atomically so a binding
// failure cannot leak a half-built trigger row.
if req.TriggerID == "" {
if err := validateTriggerRequest(*req.Inline); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
_, b, err := s.store.CreateTriggerWithBindingTx(
buildTriggerFromRequest(*req.Inline),
store.WorkloadTriggerBinding{
WorkloadID: wid,
BindingConfig: string(req.BindingConfig),
Enabled: enabled,
SortOrder: req.SortOrder,
},
)
if err != nil {
if errors.Is(err, store.ErrUnique) {
respondError(w, http.StatusConflict, "a trigger with this name already exists")
return
}
slog.Error("inline trigger+binding tx", "error", err)
respondError(w, http.StatusInternalServerError, "create inline trigger+binding")
return
}
respondJSON(w, http.StatusCreated, s.toBindingView(b))
return
}
// Existing-trigger path: just bind.
trg, err := s.store.GetTriggerByID(req.TriggerID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "trigger")
return
}
respondError(w, http.StatusInternalServerError, "get trigger")
return
}
if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
b, err := s.store.CreateBinding(store.WorkloadTriggerBinding{
WorkloadID: wid,
TriggerID: req.TriggerID,
BindingConfig: string(req.BindingConfig),
Enabled: enabled,
SortOrder: req.SortOrder,
})
if err != nil {
if errors.Is(err, store.ErrUnique) {
respondError(w, http.StatusConflict, "this workload is already bound to this trigger")
return
}
slog.Error("create binding from workload side", "error", err)
respondError(w, http.StatusInternalServerError, "create binding")
return
}
respondJSON(w, http.StatusCreated, s.toBindingView(b))
}
func (s *Server) deleteBinding(w http.ResponseWriter, r *http.Request) {
bid := chi.URLParam(r, "bid")
if err := s.store.DeleteBinding(bid); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "binding")
return
}
respondError(w, http.StatusInternalServerError, "delete binding")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": bid})
}
+11 -5
View File
@@ -134,10 +134,16 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error {
return nil return nil
} }
// reconcilePluginWorkloads iterates every workload row that opted into // reconcilePluginWorkloads iterates every workload row that has a
// the plugin pipeline (source_kind + trigger_kind both set) and asks the // Source plugin and asks the dispatcher to invoke Source.Reconcile.
// dispatcher to invoke Source.Reconcile. Failures are logged per-workload // Failures are logged per-workload — one workload's broken state must
// — one workload's broken state must not stop sweeping the rest. // not stop sweeping the rest.
//
// Trigger configuration is no longer required to reconcile: a workload
// with a Source but no trigger bindings is still a deployed thing whose
// container state must stay in sync (manual-only deploys are common
// during early setup). After the trigger-split refactor triggers live
// in their own table, so the only gate here is SourceKind.
// //
// No-op when the plugin dispatcher hasn't been wired (boot-time race, // No-op when the plugin dispatcher hasn't been wired (boot-time race,
// disabled deployments, tests). // disabled deployments, tests).
@@ -151,7 +157,7 @@ func (r *Reconciler) reconcilePluginWorkloads(ctx context.Context) {
return return
} }
for _, w := range rows { for _, w := range rows {
if w.SourceKind == "" || w.TriggerKind == "" { if w.SourceKind == "" {
continue continue
} }
pw := toPluginWorkload(w) pw := toPluginWorkload(w)
+37
View File
@@ -510,6 +510,43 @@ type Container struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// Trigger is a first-class redeploy signal source. Triggers were embedded
// in workload rows (workload.trigger_kind / trigger_config) until the
// trigger-split refactor; they are now standalone records bound to
// workloads via WorkloadTriggerBinding so a single trigger (a webhook,
// registry watcher, schedule, git push) can fan out to many workloads.
//
// Webhook secrets live here, not on the workload — the inbound webhook
// URL identifies a trigger, which then resolves its bindings to decide
// which workloads to fire.
type Trigger struct {
ID string `json:"id"`
Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ...
Name string `json:"name"` // human-readable, unique
Config string `json:"config"` // JSON-encoded, decoded by the matching plugin
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is
// the per-binding override applied on top of Trigger.Config (top-level
// JSON merge: binding fields win). Empty BindingConfig means "use the
// trigger's config verbatim". Enabled false skips the binding without
// deleting it (useful for paused stages).
type WorkloadTriggerBinding struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
TriggerID string `json:"trigger_id"`
BindingConfig string `json:"binding_config"` // JSON-encoded; "{}" = none
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// App is an optional grouping of workloads (e.g., "my-saas" = web project + worker stack + redis stack). // App is an optional grouping of workloads (e.g., "my-saas" = web project + worker stack + redis stack).
// Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1. // Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1.
type App struct { type App struct {
+180
View File
@@ -4,15 +4,41 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
"github.com/google/uuid"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
// ErrNotFound is returned when a requested entity does not exist. // ErrNotFound is returned when a requested entity does not exist.
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
// ErrUnique is returned when a write violates a UNIQUE constraint.
// Translating the driver-specific message at the store boundary lets
// callers use errors.Is instead of fragile substring matching on
// err.Error(); the SQLite driver's wording is not part of any contract.
var ErrUnique = errors.New("unique constraint violation")
// translateSQLError maps a driver-level error onto one of the store's
// sentinel errors when possible. Returns the original error unchanged
// when no mapping applies. The returned error wraps the original via
// %w so callers that need the raw message still have it.
func translateSQLError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
// modernc.org/sqlite returns text like
// "constraint failed: UNIQUE constraint failed: triggers.name (2067)"
// Match case-insensitively in case the driver wording shifts.
if strings.Contains(strings.ToUpper(msg), "UNIQUE") {
return fmt.Errorf("%w: %v", ErrUnique, err)
}
return err
}
// Store wraps the SQLite database connection and provides access to all query methods. // Store wraps the SQLite database connection and provides access to all query methods.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
@@ -274,6 +300,34 @@ func (s *Store) runMigrations() error {
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workload_id, target) UNIQUE(workload_id, target)
)`, )`,
// triggers: first-class redeploy signal sources. Webhook secrets
// move from workload onto the trigger so one webhook URL can fan
// out to multiple workloads via workload_trigger_bindings.
`CREATE TABLE IF NOT EXISTS triggers (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL UNIQUE,
config TEXT NOT NULL DEFAULT '{}',
webhook_secret TEXT NOT NULL DEFAULT '',
webhook_signing_secret TEXT NOT NULL DEFAULT '',
webhook_require_signature INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
// workload_trigger_bindings: many-to-many between workloads and
// triggers. binding_config is the per-binding override applied on
// top of trigger.config (top-level JSON merge, binding wins).
`CREATE TABLE IF NOT EXISTS workload_trigger_bindings (
id TEXT PRIMARY KEY,
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
trigger_id TEXT NOT NULL REFERENCES triggers(id) ON DELETE CASCADE,
binding_config TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workload_id, trigger_id)
)`,
} }
for _, t := range workloadTables { for _, t := range workloadTables {
if _, err := s.db.Exec(t); err != nil { if _, err := s.db.Exec(t); err != nil {
@@ -454,6 +508,11 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`, `CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`, `CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`, `CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
// Trigger-split indexes (2026-05-16).
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_bindings_trigger ON workload_trigger_bindings(trigger_id)`,
} }
for _, idx := range indexes { for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil { if _, err := s.db.Exec(idx); err != nil {
@@ -474,6 +533,127 @@ func (s *Store) runMigrations() error {
} }
} }
if err := s.backfillTriggersFromWorkloads(); err != nil {
slog.Warn("trigger backfill", "error", err)
}
return nil
}
// backfillTriggersFromWorkloads converts embedded trigger config on
// workload rows into standalone trigger + binding rows. Runs once per
// boot and is idempotent — only workloads with non-empty trigger_kind
// AND no existing binding produce a new trigger record.
//
// Each per-workload backfill runs inside a transaction so a partial
// failure (binding insert fails after trigger insert succeeds) rolls
// back cleanly; otherwise an orphan trigger row would survive forever
// because the next boot's bindings-count check sees zero bindings and
// tries to re-insert under the same UNIQUE name.
//
// Trigger names are unconditionally suffixed with the workload's id
// short-prefix to make collisions impossible across two workloads with
// identical (name, kind) — the "Foo [registry]" + "Foo [registry]" case
// would otherwise have one of them silently dropped.
//
// Why on every boot: the trigger-split refactor is a clean break (no
// formal migration). Existing dev databases have triggers embedded in
// workloads.trigger_kind / trigger_config; this lifts them into the new
// shape so URLs and behavior survive the upgrade.
func (s *Store) backfillTriggersFromWorkloads() error {
rows, err := s.db.Query(
`SELECT id, name, trigger_kind, trigger_config,
webhook_secret, webhook_signing_secret, webhook_require_signature
FROM workloads
WHERE trigger_kind != ''`,
)
if err != nil {
return fmt.Errorf("scan workloads for backfill: %w", err)
}
defer rows.Close()
type embedded struct {
id, name, kind, config string
webhookSecret, webhookSigningSecret string
requireSig int
}
var pending []embedded
for rows.Next() {
var e embedded
if err := rows.Scan(&e.id, &e.name, &e.kind, &e.config,
&e.webhookSecret, &e.webhookSigningSecret, &e.requireSig); err != nil {
return fmt.Errorf("scan workload row: %w", err)
}
pending = append(pending, e)
}
if err := rows.Err(); err != nil {
return err
}
for _, e := range pending {
if err := s.backfillOneTrigger(e.id, e.name, e.kind, e.config,
e.webhookSecret, e.webhookSigningSecret, e.requireSig); err != nil {
slog.Warn("trigger backfill: workload skipped",
"workload", e.id, "error", err)
}
}
return nil
}
// backfillOneTrigger lifts one embedded trigger into its own row + binding
// inside a single transaction. Idempotent: a workload that already has at
// least one binding is left alone.
func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config,
webhookSecret, webhookSigningSecret string, requireSig int) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
var existing int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM workload_trigger_bindings WHERE workload_id = ?`,
workloadID,
).Scan(&existing); err != nil {
return fmt.Errorf("count bindings: %w", err)
}
if existing > 0 {
return nil
}
idShort := workloadID
if len(idShort) > 8 {
idShort = idShort[:8]
}
triggerName := fmt.Sprintf("%s [%s] %s", workloadName, kind, idShort)
triggerID := uuid.New().String()
now := Now()
if _, err := tx.Exec(
`INSERT INTO triggers (id, kind, name, config,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
triggerID, kind, triggerName, config,
webhookSecret, webhookSigningSecret, requireSig,
now, now,
); err != nil {
return fmt.Errorf("insert trigger: %w", err)
}
bindingID := uuid.New().String()
if _, err := tx.Exec(
`INSERT INTO workload_trigger_bindings
(id, workload_id, trigger_id, binding_config, enabled, sort_order, created_at, updated_at)
VALUES (?, ?, ?, '{}', 1, 0, ?, ?)`,
bindingID, workloadID, triggerID, now, now,
); err != nil {
return fmt.Errorf("insert binding: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil return nil
} }
+303
View File
@@ -0,0 +1,303 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const triggerColumns = `id, kind, name, config,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at`
func scanTrigger(s rowScanner) (Trigger, error) {
var t Trigger
var requireSig int
if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt); err != nil {
return Trigger{}, err
}
t.WebhookRequireSignature = requireSig != 0
return t, nil
}
// CreateTrigger inserts a new trigger row. Kind + Name are required.
// Config is normalized to "{}" when empty; webhook secret is left empty
// unless the caller pre-populates it.
func (s *Store) CreateTrigger(t Trigger) (Trigger, error) {
if t.ID == "" {
t.ID = uuid.New().String()
}
if t.Config == "" {
t.Config = "{}"
}
t.CreatedAt = Now()
t.UpdatedAt = t.CreatedAt
_, err := s.db.Exec(
`INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt,
)
if err != nil {
return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
}
return t, nil
}
// GetTriggerByID returns one trigger.
func (s *Store) GetTriggerByID(id string) (Trigger, error) {
t, err := scanTrigger(s.db.QueryRow(
`SELECT `+triggerColumns+` FROM triggers WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return Trigger{}, fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
if err != nil {
return Trigger{}, fmt.Errorf("query trigger: %w", err)
}
return t, nil
}
// GetTriggerByWebhookSecret resolves a trigger by its inbound webhook
// secret. Empty input is treated as not-found to avoid accidental matches
// against rows whose webhook is disabled.
func (s *Store) GetTriggerByWebhookSecret(secret string) (Trigger, error) {
if secret == "" {
return Trigger{}, fmt.Errorf("empty secret: %w", ErrNotFound)
}
t, err := scanTrigger(s.db.QueryRow(
`SELECT `+triggerColumns+` FROM triggers WHERE webhook_secret = ?`, secret,
))
if errors.Is(err, sql.ErrNoRows) {
return Trigger{}, ErrNotFound
}
if err != nil {
return Trigger{}, fmt.Errorf("query trigger by webhook secret: %w", err)
}
return t, nil
}
// GetTriggerByName resolves a trigger by its unique human name.
func (s *Store) GetTriggerByName(name string) (Trigger, error) {
if name == "" {
return Trigger{}, fmt.Errorf("empty name: %w", ErrNotFound)
}
t, err := scanTrigger(s.db.QueryRow(
`SELECT `+triggerColumns+` FROM triggers WHERE name = ?`, name,
))
if errors.Is(err, sql.ErrNoRows) {
return Trigger{}, ErrNotFound
}
if err != nil {
return Trigger{}, fmt.Errorf("query trigger by name: %w", err)
}
return t, nil
}
// ListTriggers returns all triggers, ordered by name. Optional kind
// filter — empty string returns everything.
func (s *Store) ListTriggers(kind string) ([]Trigger, error) {
var rows *sql.Rows
var err error
if kind == "" {
rows, err = s.db.Query(`SELECT ` + triggerColumns + ` FROM triggers ORDER BY name`)
} else {
rows, err = s.db.Query(`SELECT `+triggerColumns+` FROM triggers WHERE kind = ? ORDER BY name`, kind)
}
if err != nil {
return nil, fmt.Errorf("query triggers: %w", err)
}
defer rows.Close()
out := []Trigger{}
for rows.Next() {
t, err := scanTrigger(rows)
if err != nil {
return nil, fmt.Errorf("scan trigger: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// TriggerWithBindingCount projects a Trigger plus its current binding
// count in a single round-trip. Used by /api/triggers list rendering so
// the response avoids one COUNT(*) per trigger row.
type TriggerWithBindingCount struct {
Trigger
BindingCount int
}
// ListTriggersWithBindingCount returns every trigger ordered by name
// with the count of its bindings joined in. Optional kind filter.
func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingCount, error) {
const base = `
SELECT t.id, t.kind, t.name, t.config,
t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature,
t.created_at, t.updated_at,
COALESCE(b.cnt, 0)
FROM triggers t
LEFT JOIN (
SELECT trigger_id, COUNT(*) AS cnt
FROM workload_trigger_bindings
GROUP BY trigger_id
) b ON b.trigger_id = t.id`
var rows *sql.Rows
var err error
if kind == "" {
rows, err = s.db.Query(base + ` ORDER BY t.name`)
} else {
rows, err = s.db.Query(base+` WHERE t.kind = ? ORDER BY t.name`, kind)
}
if err != nil {
return nil, fmt.Errorf("query triggers with binding count: %w", err)
}
defer rows.Close()
out := []TriggerWithBindingCount{}
for rows.Next() {
var t Trigger
var requireSig int
var count int
if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt, &count); err != nil {
return nil, fmt.Errorf("scan trigger+count: %w", err)
}
t.WebhookRequireSignature = requireSig != 0
out = append(out, TriggerWithBindingCount{Trigger: t, BindingCount: count})
}
return out, rows.Err()
}
// UpdateTrigger updates the mutable fields of a trigger. Kind is
// immutable post-create — changing kinds would invalidate every
// binding's interpretation of binding_config.
func (s *Store) UpdateTrigger(t Trigger) error {
t.UpdatedAt = Now()
if t.Config == "" {
t.Config = "{}"
}
result, err := s.db.Exec(
`UPDATE triggers SET name=?, config=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=?
WHERE id=?`,
t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.UpdatedAt, t.ID,
)
if err != nil {
return fmt.Errorf("update trigger: %w", translateSQLError(err))
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", t.ID, ErrNotFound)
}
return nil
}
// DeleteTrigger removes a trigger row. Bindings cascade away via FK.
func (s *Store) DeleteTrigger(id string) error {
result, err := s.db.Exec(`DELETE FROM triggers WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete trigger: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
return nil
}
// CreateTriggerWithBindingTx atomically creates a trigger row and
// binds it to a workload. Used by the workload-side inline-create-and-
// bind endpoint so a binding-insert failure does not leave an orphan
// trigger row behind. Returns the persisted trigger and binding.
func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding) (Trigger, WorkloadTriggerBinding, error) {
tx, err := s.db.Begin()
if err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if t.ID == "" {
t.ID = uuid.New().String()
}
if t.Config == "" {
t.Config = "{}"
}
t.CreatedAt = Now()
t.UpdatedAt = t.CreatedAt
if _, err := tx.Exec(
`INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt,
); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
}
if b.ID == "" {
b.ID = uuid.New().String()
}
if b.BindingConfig == "" {
b.BindingConfig = "{}"
}
b.TriggerID = t.ID
b.CreatedAt = t.CreatedAt
b.UpdatedAt = t.UpdatedAt
if _, err := tx.Exec(
`INSERT INTO workload_trigger_bindings (`+bindingColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
b.ID, b.WorkloadID, b.TriggerID, b.BindingConfig,
BoolToInt(b.Enabled), b.SortOrder, b.CreatedAt, b.UpdatedAt,
); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err))
}
if err := tx.Commit(); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("commit: %w", err)
}
return t, b, nil
}
// SetTriggerWebhookSecret rotates the inbound webhook URL secret. Pass
// empty string to disable webhook ingress for this trigger.
func (s *Store) SetTriggerWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE triggers SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("update trigger webhook_secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureTriggerWebhookSecret returns the current secret, generating one
// lazily for triggers that have none. Mirrors the workload helper.
func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
t, err := s.GetTriggerByID(id)
if err != nil {
return "", err
}
if t.WebhookSecret != "" {
return t.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetTriggerWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
+270
View File
@@ -0,0 +1,270 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const bindingColumns = `id, workload_id, trigger_id, binding_config,
enabled, sort_order, created_at, updated_at`
func scanBinding(s rowScanner) (WorkloadTriggerBinding, error) {
var b WorkloadTriggerBinding
var enabled int
if err := s.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig,
&enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt); err != nil {
return WorkloadTriggerBinding{}, err
}
b.Enabled = enabled != 0
return b, nil
}
// CreateBinding inserts a binding row. The (workload_id, trigger_id) pair
// must be unique — re-binding an existing pair is an UpdateBinding call,
// not an insert.
func (s *Store) CreateBinding(b WorkloadTriggerBinding) (WorkloadTriggerBinding, error) {
if b.ID == "" {
b.ID = uuid.New().String()
}
if b.BindingConfig == "" {
b.BindingConfig = "{}"
}
b.CreatedAt = Now()
b.UpdatedAt = b.CreatedAt
_, err := s.db.Exec(
`INSERT INTO workload_trigger_bindings (`+bindingColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
b.ID, b.WorkloadID, b.TriggerID, b.BindingConfig,
BoolToInt(b.Enabled), b.SortOrder, b.CreatedAt, b.UpdatedAt,
)
if err != nil {
return WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err))
}
return b, nil
}
// GetBindingByID returns one binding by its primary key.
func (s *Store) GetBindingByID(id string) (WorkloadTriggerBinding, error) {
b, err := scanBinding(s.db.QueryRow(
`SELECT `+bindingColumns+` FROM workload_trigger_bindings WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return WorkloadTriggerBinding{}, fmt.Errorf("binding %s: %w", id, ErrNotFound)
}
if err != nil {
return WorkloadTriggerBinding{}, fmt.Errorf("query binding: %w", err)
}
return b, nil
}
// ListBindingsForWorkload returns every trigger bound to a workload,
// ordered by sort_order then created_at for stable display.
func (s *Store) ListBindingsForWorkload(workloadID string) ([]WorkloadTriggerBinding, error) {
rows, err := s.db.Query(
`SELECT `+bindingColumns+` FROM workload_trigger_bindings
WHERE workload_id = ? ORDER BY sort_order, created_at`,
workloadID,
)
if err != nil {
return nil, fmt.Errorf("query bindings for workload: %w", err)
}
defer rows.Close()
out := []WorkloadTriggerBinding{}
for rows.Next() {
b, err := scanBinding(rows)
if err != nil {
return nil, fmt.Errorf("scan binding: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// ListBindingsForTrigger returns every workload bound to a trigger,
// ordered by sort_order. Used by the webhook fan-out path.
func (s *Store) ListBindingsForTrigger(triggerID string) ([]WorkloadTriggerBinding, error) {
rows, err := s.db.Query(
`SELECT `+bindingColumns+` FROM workload_trigger_bindings
WHERE trigger_id = ? ORDER BY sort_order, created_at`,
triggerID,
)
if err != nil {
return nil, fmt.Errorf("query bindings for trigger: %w", err)
}
defer rows.Close()
out := []WorkloadTriggerBinding{}
for rows.Next() {
b, err := scanBinding(rows)
if err != nil {
return nil, fmt.Errorf("scan binding: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// GetBindingByPair returns the binding for an exact (workload, trigger)
// pair. ErrNotFound when missing.
func (s *Store) GetBindingByPair(workloadID, triggerID string) (WorkloadTriggerBinding, error) {
b, err := scanBinding(s.db.QueryRow(
`SELECT `+bindingColumns+` FROM workload_trigger_bindings
WHERE workload_id = ? AND trigger_id = ?`,
workloadID, triggerID,
))
if errors.Is(err, sql.ErrNoRows) {
return WorkloadTriggerBinding{}, ErrNotFound
}
if err != nil {
return WorkloadTriggerBinding{}, fmt.Errorf("query binding by pair: %w", err)
}
return b, nil
}
// UpdateBinding updates the mutable fields of a binding (binding_config,
// enabled, sort_order). The (workload_id, trigger_id) pair is immutable
// — to re-target, delete and re-insert.
func (s *Store) UpdateBinding(b WorkloadTriggerBinding) error {
b.UpdatedAt = Now()
if b.BindingConfig == "" {
b.BindingConfig = "{}"
}
result, err := s.db.Exec(
`UPDATE workload_trigger_bindings
SET binding_config=?, enabled=?, sort_order=?, updated_at=?
WHERE id=?`,
b.BindingConfig, BoolToInt(b.Enabled), b.SortOrder, b.UpdatedAt, b.ID,
)
if err != nil {
return fmt.Errorf("update binding: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("binding %s: %w", b.ID, ErrNotFound)
}
return nil
}
// DeleteBinding removes a binding row.
func (s *Store) DeleteBinding(id string) error {
result, err := s.db.Exec(
`DELETE FROM workload_trigger_bindings WHERE id = ?`, id,
)
if err != nil {
return fmt.Errorf("delete binding: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("binding %s: %w", id, ErrNotFound)
}
return nil
}
// DeleteBindingsForWorkload removes every binding for a workload.
// Idempotent — returns nil even if no rows existed.
func (s *Store) DeleteBindingsForWorkload(workloadID string) error {
_, err := s.db.Exec(
`DELETE FROM workload_trigger_bindings WHERE workload_id = ?`,
workloadID,
)
if err != nil {
return fmt.Errorf("delete bindings for workload: %w", err)
}
return nil
}
// BindingWithNames carries a binding plus the human names of its
// workload + trigger so the API listing endpoints render in one
// round-trip instead of N+1 lookups.
type BindingWithNames struct {
WorkloadTriggerBinding
WorkloadName string
TriggerKind string
TriggerName string
}
// ListBindingsForTriggerWithNames is the join-aware variant of
// ListBindingsForTrigger that also surfaces the workload's name.
func (s *Store) ListBindingsForTriggerWithNames(triggerID string) ([]BindingWithNames, error) {
rows, err := s.db.Query(
`SELECT b.id, b.workload_id, b.trigger_id, b.binding_config,
b.enabled, b.sort_order, b.created_at, b.updated_at,
COALESCE(w.name, '')
FROM workload_trigger_bindings b
LEFT JOIN workloads w ON w.id = b.workload_id
WHERE b.trigger_id = ?
ORDER BY b.sort_order, b.created_at`,
triggerID,
)
if err != nil {
return nil, fmt.Errorf("query bindings+names for trigger: %w", err)
}
defer rows.Close()
out := []BindingWithNames{}
for rows.Next() {
var b WorkloadTriggerBinding
var enabled int
var workloadName string
if err := rows.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig,
&enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt, &workloadName); err != nil {
return nil, fmt.Errorf("scan binding+name: %w", err)
}
b.Enabled = enabled != 0
out = append(out, BindingWithNames{WorkloadTriggerBinding: b, WorkloadName: workloadName})
}
return out, rows.Err()
}
// ListBindingsForWorkloadWithNames is the join-aware variant of
// ListBindingsForWorkload that also surfaces the trigger's kind + name.
func (s *Store) ListBindingsForWorkloadWithNames(workloadID string) ([]BindingWithNames, error) {
rows, err := s.db.Query(
`SELECT b.id, b.workload_id, b.trigger_id, b.binding_config,
b.enabled, b.sort_order, b.created_at, b.updated_at,
COALESCE(t.kind, ''), COALESCE(t.name, '')
FROM workload_trigger_bindings b
LEFT JOIN triggers t ON t.id = b.trigger_id
WHERE b.workload_id = ?
ORDER BY b.sort_order, b.created_at`,
workloadID,
)
if err != nil {
return nil, fmt.Errorf("query bindings+names for workload: %w", err)
}
defer rows.Close()
out := []BindingWithNames{}
for rows.Next() {
var b WorkloadTriggerBinding
var enabled int
var kind, name string
if err := rows.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig,
&enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt, &kind, &name); err != nil {
return nil, fmt.Errorf("scan binding+trigger names: %w", err)
}
b.Enabled = enabled != 0
out = append(out, BindingWithNames{
WorkloadTriggerBinding: b,
TriggerKind: kind,
TriggerName: name,
})
}
return out, rows.Err()
}
// CountBindingsForTrigger returns the number of bindings a trigger has.
// Used by the UI to decide whether deleting a trigger is safe.
func (s *Store) CountBindingsForTrigger(triggerID string) (int, error) {
var n int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM workload_trigger_bindings WHERE trigger_id = ?`,
triggerID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count bindings for trigger: %w", err)
}
return n, nil
}
+7 -167
View File
@@ -13,7 +13,6 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -297,11 +296,16 @@ func (h *Handler) Drain() {
// //
// POST /{secret} — per-project deploy trigger (legacy) // POST /{secret} — per-project deploy trigger (legacy)
// POST /sites/{secret} — per-site sync trigger (legacy) // POST /sites/{secret} — per-site sync trigger (legacy)
// POST /workloads/{secret} — plugin-native workload trigger // POST /triggers/{secret} first-class trigger fan-out to all bound workloads
//
// The legacy POST /workloads/{secret} route was dropped in the
// trigger-split refactor. Existing inbound webhook secrets were lifted
// into trigger rows by the boot backfill, so the same secret value
// works at /triggers/{secret} after the upgrade.
func (h *Handler) Route() chi.Router { func (h *Handler) Route() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/sites/{secret}", h.handleSiteWebhook) r.Post("/sites/{secret}", h.handleSiteWebhook)
r.Post("/workloads/{secret}", h.handlePluginWorkloadWebhook) r.Post("/triggers/{secret}", h.handleTriggerWebhook)
r.Post("/{secret}", h.handleWebhook) r.Post("/{secret}", h.handleWebhook)
return r return r
} }
@@ -675,170 +679,6 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handlePluginWorkloadWebhook processes an inbound webhook for a
// plugin-native workload.
//
// URL: POST /api/webhook/workloads/{secret}
//
// The secret resolves to exactly one workload row whose Source +
// Trigger kinds determine how the payload is interpreted. The body
// shape is the same as the legacy project/site webhooks (Image for
// registry pushes, Ref for git pushes) — Gitea / GitHub / generic
// registry CIs can target this URL without payload changes. The
// workload's configured Trigger plugin then decides whether the event
// fires a deploy.
func (h *Handler) handlePluginWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "workload",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.plugins == nil {
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "plugin dispatcher not wired"
respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired")
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
wl, err := h.store.GetWorkloadByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: workload lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
if wl.SourceKind == "" || wl.TriggerKind == "" {
// Legacy workload row whose secret happens to also be valid on the
// legacy path. Tell the caller they hit the wrong route rather
// than silently 404-ing — avoids head-scratching.
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "workload is legacy; use the project or site route"
respondWebhookError(w, http.StatusBadRequest, "workload is not plugin-native")
return
}
delivery.TargetID = wl.ID
delivery.TargetName = wl.Name
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(wl.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(wl.WebhookSigningSecret, header, verified, attempted)
if wl.WebhookRequireSignature && !verified {
slog.Warn("webhook: workload signature required but invalid/missing", "workload", wl.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: workload bad signature", "workload", wl.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
evt, err := buildInboundEvent(body, r.Header)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = err.Error()
respondWebhookError(w, http.StatusBadRequest, err.Error())
return
}
trig, err := plugin.GetTrigger(wl.TriggerKind)
if err != nil {
slog.Warn("webhook: trigger plugin not registered",
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger plugin missing"
respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing")
return
}
pwl := toPluginWorkload(wl)
intent, err := trig.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
if err != nil {
slog.Warn("webhook: trigger match error",
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger match error"
respondWebhookError(w, http.StatusInternalServerError, "trigger match error")
return
}
if intent == nil {
delivery.Detail = "trigger declined (no match)"
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "workload": wl.Name,
"reason": "trigger declined",
})
return
}
if intent.TriggeredAt.IsZero() {
intent.TriggeredAt = time.Now().UTC()
}
if intent.TriggeredBy == "" {
intent.TriggeredBy = "webhook"
}
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
slog.Warn("webhook: plugin dispatch failed",
"workload", wl.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "dispatch failed; see server logs"
respondWebhookError(w, http.StatusInternalServerError, "dispatch failed; see server logs")
return
}
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("reason=%s ref=%s", intent.Reason, intent.Reference)
slog.Info("webhook: triggered plugin deploy",
"workload", wl.Name, "trigger", wl.TriggerKind, "reason", intent.Reason)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"workload": wl.Name, "reference": intent.Reference,
})
}
// buildInboundEvent normalizes the incoming webhook body into the // buildInboundEvent normalizes the incoming webhook body into the
// plugin.InboundEvent shape. The dispatch order is: // plugin.InboundEvent shape. The dispatch order is:
// //
+288
View File
@@ -0,0 +1,288 @@
package webhook
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// maxTriggerFanOutConcurrency caps how many bindings dispatch in
// parallel for a single trigger webhook. Sequential fan-out would hold
// the request goroutine for the sum of every binding's deploy time —
// minutes for an N-binding trigger. Bounding to 4 keeps wall-clock
// roughly N/4 * deploy_time without saturating the docker daemon (which
// already serializes pulls).
const maxTriggerFanOutConcurrency = 4
// bindingResult is the per-binding entry in the trigger fan-out
// response body.
type bindingResult struct {
Workload string `json:"workload"`
Deployed bool `json:"deployed"`
Reason string `json:"reason,omitempty"`
}
// handleTriggerWebhook processes an inbound webhook for a first-class
// Trigger record. The secret resolves to one Trigger; the Trigger then
// fans out to every enabled workload binding. Each binding gets its
// effective config (trigger.config + binding.binding_config merged) and
// runs through the trigger plugin's Match independently — one binding
// firing does not affect another.
//
// URL: POST /api/webhook/triggers/{secret}
//
// Response shape: aggregate counts so a CI can tell at a glance whether
// any deploys fired (status 200 + deploys=N) without parsing per-binding
// detail. Errors per-binding are logged at warn level but do not fail
// the whole request — one broken workload should not block the others.
func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "trigger",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.plugins == nil {
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "plugin dispatcher not wired"
respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired")
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
trg, err := h.store.GetTriggerByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: trigger lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = trg.ID
delivery.TargetName = trg.Name
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(trg.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(trg.WebhookSigningSecret, header, verified, attempted)
if trg.WebhookRequireSignature && !verified {
slog.Warn("webhook: trigger signature required but invalid/missing", "trigger", trg.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: trigger bad signature", "trigger", trg.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
evt, err := buildInboundEvent(body, r.Header)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = err.Error()
respondWebhookError(w, http.StatusBadRequest, err.Error())
return
}
trigPlugin, err := plugin.GetTrigger(trg.Kind)
if err != nil {
slog.Warn("webhook: trigger plugin not registered",
"trigger", trg.Name, "kind", trg.Kind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger plugin missing"
respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing")
return
}
bindings, err := h.store.ListBindingsForTrigger(trg.ID)
if err != nil {
slog.Error("webhook: list bindings failed", "trigger", trg.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "list bindings failed"
respondWebhookError(w, http.StatusInternalServerError, "list bindings failed")
return
}
results := h.fanOutBindings(ctx, trg, trigPlugin, bindings, evt)
var deployed, skipped, noMatch, errored int
for _, r := range results {
switch {
case r.Deployed:
deployed++
case r.Reason == "binding disabled":
skipped++
case r.Reason == "no match":
noMatch++
default:
errored++
}
}
switch {
case deployed > 0:
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("deployed=%d of %d (errored=%d, skipped=%d)",
deployed, len(results), errored, skipped)
case errored > 0:
delivery.Outcome = outcomeError
delivery.Detail = fmt.Sprintf("errored=%d of %d", errored, len(results))
case skipped == len(results):
delivery.Detail = "all bindings disabled"
case noMatch == len(results)-skipped:
delivery.Detail = "no binding matched"
default:
delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d", skipped, errored)
}
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true,
"trigger": trg.Name,
"deployed": deployed,
"bindings": results,
})
}
// fanOutBindings dispatches every binding through fireBinding with at
// most maxTriggerFanOutConcurrency goroutines in flight. Order of the
// returned slice matches the input bindings slice so callers can rely
// on positional correlation.
//
// Disabled bindings short-circuit on the orchestrator goroutine — they
// don't take a worker slot, leaving the pool free for real dispatches.
// Workload-missing rows are recorded as errors and also skip the pool.
func (h *Handler) fanOutBindings(
ctx context.Context,
trg store.Trigger,
trigPlugin plugin.Trigger,
bindings []store.WorkloadTriggerBinding,
evt plugin.InboundEvent,
) []bindingResult {
results := make([]bindingResult, len(bindings))
concurrency := maxTriggerFanOutConcurrency
if len(bindings) < concurrency {
concurrency = len(bindings)
}
if concurrency < 1 {
concurrency = 1
}
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i, b := range bindings {
if !b.Enabled {
results[i] = bindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "binding disabled"}
continue
}
row, lookupErr := h.store.GetWorkloadByID(b.WorkloadID)
if lookupErr != nil {
slog.Warn("webhook: bound workload missing",
"trigger", trg.Name, "workload", b.WorkloadID, "error", lookupErr)
results[i] = bindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "workload missing"}
continue
}
wg.Add(1)
sem <- struct{}{}
go func(idx int, binding store.WorkloadTriggerBinding, wl store.Workload) {
defer wg.Done()
defer func() { <-sem }()
fired, reason := h.fireBinding(ctx, trg, trigPlugin, wl, binding, evt)
results[idx] = bindingResult{Workload: wl.Name, Deployed: fired, Reason: reason}
}(i, b, row)
}
wg.Wait()
return results
}
// fireBinding runs Match for one binding and dispatches if intent.
// Returns (fired, human-readable reason). Errors are logged but the
// reason is kept generic on the wire so a malformed binding does not
// leak internals.
func (h *Handler) fireBinding(
ctx context.Context,
trg store.Trigger,
trigPlugin plugin.Trigger,
row store.Workload,
b store.WorkloadTriggerBinding,
evt plugin.InboundEvent,
) (bool, string) {
pwl := toPluginWorkload(row)
pwl, err := plugin.WithEffectiveTrigger(pwl, trg.Kind,
json.RawMessage(trg.Config), json.RawMessage(b.BindingConfig))
if err != nil {
slog.Warn("webhook: merge effective trigger config failed",
"trigger", trg.Name, "workload", row.Name, "error", err)
return false, "config merge error"
}
intent, err := trigPlugin.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
if err != nil {
slog.Warn("webhook: trigger match error",
"trigger", trg.Name, "workload", row.Name, "error", err)
return false, "match error"
}
if intent == nil {
return false, "no match"
}
if intent.TriggeredAt.IsZero() {
intent.TriggeredAt = time.Now().UTC()
}
if intent.TriggeredBy == "" {
intent.TriggeredBy = "trigger-webhook"
}
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
slog.Warn("webhook: dispatch failed",
"trigger", trg.Name, "workload", row.Name, "error", err)
return false, "dispatch failed"
}
slog.Info("webhook: triggered deploy via trigger fan-out",
"trigger", trg.Name, "workload", row.Name, "reason", intent.Reason)
return true, intent.Reason
}
+57
View File
@@ -0,0 +1,57 @@
package plugin
import (
"encoding/json"
"fmt"
)
// MergeJSONConfig merges override on top of base at the top-level
// (binding fields win, base fields fill the rest). Both inputs are
// expected to be JSON objects ("{}" when empty); arrays or scalars are
// rejected because trigger and binding configs are always objects.
//
// Always returns a freshly allocated slice so callers may freely mutate
// the result without affecting base. The fast-path (override empty)
// returns a defensive copy of base for the same reason.
func MergeJSONConfig(base, override json.RawMessage) (json.RawMessage, error) {
if len(override) == 0 || string(override) == "{}" {
if len(base) == 0 {
return json.RawMessage("{}"), nil
}
return append(json.RawMessage(nil), base...), nil
}
if len(base) == 0 || string(base) == "{}" {
return append(json.RawMessage(nil), override...), nil
}
baseMap := map[string]json.RawMessage{}
if err := json.Unmarshal(base, &baseMap); err != nil {
return nil, fmt.Errorf("merge config: base is not a JSON object: %w", err)
}
overMap := map[string]json.RawMessage{}
if err := json.Unmarshal(override, &overMap); err != nil {
return nil, fmt.Errorf("merge config: override is not a JSON object: %w", err)
}
for k, v := range overMap {
baseMap[k] = v
}
out, err := json.Marshal(baseMap)
if err != nil {
return nil, fmt.Errorf("merge config: re-marshal: %w", err)
}
return out, nil
}
// WithEffectiveTrigger returns a copy of w with TriggerKind and
// TriggerConfig set from a resolved (trigger, binding) pair. The
// existing Trigger.Match contract reads w.TriggerConfig via
// TriggerConfigOf[T], so this is the seam that lets the trigger plugins
// stay unchanged after the trigger-split refactor.
func WithEffectiveTrigger(w Workload, kind string, triggerConfig, bindingConfig json.RawMessage) (Workload, error) {
merged, err := MergeJSONConfig(triggerConfig, bindingConfig)
if err != nil {
return Workload{}, err
}
w.TriggerKind = kind
w.TriggerConfig = merged
return w, nil
}
+122
View File
@@ -1186,6 +1186,128 @@ export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise<H
return get<HookKindSchema>(`/api/hooks/kinds/${kind}/schema`, signal); return get<HookKindSchema>(`/api/hooks/kinds/${kind}/schema`, signal);
} }
// ── Triggers (first-class redeploy signal sources) ──────────────────
export interface RedeployTrigger {
id: string;
kind: string;
name: string;
config: unknown;
webhook_enabled: boolean;
webhook_require_signature: boolean;
binding_count: number;
created_at: string;
updated_at: string;
}
export interface TriggerWebhook {
url: string;
secret: string;
webhook_require_signature: boolean;
}
export interface TriggerBinding {
id: string;
workload_id: string;
workload_name: string;
trigger_id: string;
binding_config: unknown;
enabled: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface WorkloadTriggerBinding extends TriggerBinding {
trigger_kind: string;
trigger_name: string;
}
export interface TriggerInput {
kind: string;
name: string;
config: unknown;
webhook_enabled: boolean;
webhook_require_signature: boolean;
}
export interface BindingInput {
workload_id: string;
binding_config?: unknown;
enabled?: boolean;
sort_order?: number;
}
export interface WorkloadBindInput {
trigger_id?: string;
binding_config?: unknown;
enabled?: boolean;
sort_order?: number;
inline?: TriggerInput;
}
export function listTriggers(kind?: string, signal?: AbortSignal): Promise<RedeployTrigger[]> {
const path = kind ? `/api/triggers?kind=${encodeURIComponent(kind)}` : '/api/triggers';
return get<RedeployTrigger[]>(path, signal);
}
export function getTrigger(id: string, signal?: AbortSignal): Promise<RedeployTrigger> {
return get<RedeployTrigger>(`/api/triggers/${id}`, signal);
}
export function createTrigger(body: TriggerInput): Promise<RedeployTrigger> {
return post<RedeployTrigger>('/api/triggers', body);
}
export function updateTrigger(id: string, body: TriggerInput): Promise<RedeployTrigger> {
return put<RedeployTrigger>(`/api/triggers/${id}`, body);
}
export function deleteTrigger(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/triggers/${id}`);
}
export function getTriggerWebhook(id: string, signal?: AbortSignal): Promise<TriggerWebhook> {
return get<TriggerWebhook>(`/api/triggers/${id}/webhook`, signal);
}
export function regenerateTriggerWebhook(id: string): Promise<{ secret: string; url: string }> {
return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`);
}
export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise<TriggerBinding[]> {
return get<TriggerBinding[]>(`/api/triggers/${id}/bindings`, signal);
}
export function bindWorkloadToTrigger(triggerId: string, body: BindingInput): Promise<TriggerBinding> {
return post<TriggerBinding>(`/api/triggers/${triggerId}/bindings`, body);
}
export function listBindingsForWorkload(
workloadId: string,
signal?: AbortSignal
): Promise<WorkloadTriggerBinding[]> {
return get<WorkloadTriggerBinding[]>(`/api/workloads/${workloadId}/triggers`, signal);
}
export function bindTriggerToWorkload(
workloadId: string,
body: WorkloadBindInput
): Promise<TriggerBinding> {
return post<TriggerBinding>(`/api/workloads/${workloadId}/triggers`, body);
}
export function updateBinding(
id: string,
body: { binding_config?: unknown; enabled?: boolean; sort_order?: number }
): Promise<TriggerBinding> {
return put<TriggerBinding>(`/api/bindings/${id}`, body);
}
export function deleteBinding(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/bindings/${id}`);
}
export interface WorkloadChainNode { export interface WorkloadChainNode {
id: string; id: string;
name: string; name: string;
@@ -0,0 +1,714 @@
<!--
Shared per-kind trigger configuration form.
Mirrors the structured form used in /triggers/new so that the
workload-side "Add trigger" surfaces (apps/new + apps/[id]) can
reuse the same UX without copy-pasting field markup.
Owns:
- kind (registry | git | manual | <unknown>)
- name (always required)
- webhook_enabled + webhook_require_signature
- the structured per-kind config slots (image/tag, repo/mode/branch/tag)
- an advanced JSON escape hatch
Exposes a `build()` method on the bindable `state` prop so callers can
read out a `TriggerInput` body when they're ready to submit.
-->
<script lang="ts" module>
import type { TriggerInput } from '$lib/api';
export const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
export type KnownTriggerKind = (typeof KNOWN_KINDS)[number];
/**
* State shared between the component and its parent. The parent owns
* one of these and binds it; the component mutates fields in place
* and exposes `build()` to materialize a submission body.
*/
export interface TriggerKindFormState {
kind: KnownTriggerKind | string;
name: string;
webhookEnabled: boolean;
webhookRequireSig: boolean;
useAdvancedJson: boolean;
// registry
regImage: string;
regTagPattern: string;
// git
gitRepo: string;
gitMode: 'push' | 'tag';
gitBranch: string;
gitTagPattern: string;
// JSON fallback
jsonText: string;
}
export function createTriggerKindFormState(
init: Partial<TriggerKindFormState> = {}
): TriggerKindFormState {
return {
kind: init.kind ?? 'registry',
name: init.name ?? '',
webhookEnabled: init.webhookEnabled ?? false,
webhookRequireSig: init.webhookRequireSig ?? true,
useAdvancedJson: init.useAdvancedJson ?? false,
regImage: init.regImage ?? '',
regTagPattern: init.regTagPattern ?? '*',
gitRepo: init.gitRepo ?? '',
gitMode: init.gitMode ?? 'push',
gitBranch: init.gitBranch ?? 'main',
gitTagPattern: init.gitTagPattern ?? 'v*',
jsonText: init.jsonText ?? ''
};
}
function isKnownKind(k: string): k is KnownTriggerKind {
return (KNOWN_KINDS as readonly string[]).includes(k);
}
export function isTriggerFormValid(s: TriggerKindFormState): boolean {
if (!s.name.trim()) return false;
if (s.useAdvancedJson) {
if (!s.jsonText.trim()) return true;
try {
const parsed = JSON.parse(s.jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
}
switch (s.kind) {
case 'registry':
return !!s.regImage.trim();
case 'git':
return !!s.gitRepo.trim();
case 'manual':
return true;
default:
// Unknown kinds without an advanced JSON payload are unsubmittable.
return false;
}
}
export function buildTriggerInput(s: TriggerKindFormState): TriggerInput {
let config: unknown;
if (s.useAdvancedJson) {
config = s.jsonText.trim() ? JSON.parse(s.jsonText) : {};
} else if (s.kind === 'registry') {
config = {
image: s.regImage.trim(),
tag_pattern: s.regTagPattern.trim() || '*'
};
} else if (s.kind === 'git') {
config =
s.gitMode === 'push'
? { repo: s.gitRepo.trim(), mode: 'push', branch: s.gitBranch.trim() || 'main' }
: {
repo: s.gitRepo.trim(),
mode: 'tag',
tag_pattern: s.gitTagPattern.trim() || '*'
};
} else if (s.kind === 'manual') {
config = {};
} else {
config = {};
}
return {
kind: s.kind,
name: s.name.trim(),
config,
webhook_enabled: s.webhookEnabled,
webhook_require_signature: s.webhookRequireSig
};
}
</script>
<script lang="ts">
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
interface Props {
/** Bound state container. */
state: TriggerKindFormState;
/** Optional id prefix to make labels unique when several copies are on-page. */
idPrefix?: string;
/** Render the name field above the per-kind config. Default true. */
showName?: boolean;
/** Render the webhook toggles below the config. Default true. */
showWebhook?: boolean;
/** Allow the operator to pick a kind. Default true (false on edit). */
showKindPicker?: boolean;
}
let {
state = $bindable(),
idPrefix = 'tk',
showName = true,
showWebhook = true,
showKindPicker = true
}: Props = $props();
const jsonValid = $derived.by(() => {
if (!state.useAdvancedJson) return true;
if (!state.jsonText.trim()) return true;
try {
const parsed = JSON.parse(state.jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
function toggleAdvanced(): void {
state.useAdvancedJson = !state.useAdvancedJson;
if (state.useAdvancedJson && !state.jsonText.trim()) {
// Seed JSON from current structured values so the operator can
// refine rather than retype.
try {
const built = buildTriggerInput(state);
state.jsonText = JSON.stringify(built.config ?? {}, null, 2);
} catch {
state.jsonText = '{\n \n}';
}
}
}
</script>
<div class="tk-form">
{#if showKindPicker}
<fieldset class="field group">
<legend class="sub-label">{$t('redeployTriggers.form.kindLabel')}</legend>
<div
class="kind-grid"
role="radiogroup"
aria-label={$t('redeployTriggers.form.kindLabel')}
>
{#each KNOWN_KINDS as k (k)}
<button
type="button"
role="radio"
aria-checked={state.kind === k}
class="kind-card"
class:active={state.kind === k}
onclick={() => (state.kind = k)}
>
<span class="kind-card-tag mono">
{$t(`redeployTriggers.kindShort.${k}`)}
</span>
<span class="kind-card-name">{$t(`redeployTriggers.kind.${k}`)}</span>
<span class="kind-card-hint">{kindHint(k)}</span>
</button>
{/each}
</div>
</fieldset>
{:else}
<div class="kind-static">
<span class="kind-tag mono">{$t(`redeployTriggers.kindShort.${state.kind}`)}</span>
<span>
{$t(`redeployTriggers.kind.${state.kind}`) === `redeployTriggers.kind.${state.kind}`
? state.kind
: $t(`redeployTriggers.kind.${state.kind}`)}
</span>
</div>
{/if}
{#if showName}
<div class="field">
<label for="{idPrefix}-name" class="sub-label">
{$t('redeployTriggers.form.name')}
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="{idPrefix}-name"
type="text"
class="input"
bind:value={state.name}
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
{/if}
<fieldset class="field group">
<legend class="sub-label legend-row">
<span>{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${state.kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={state.useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if state.useAdvancedJson}
<label class="sub" for="{idPrefix}-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="{idPrefix}-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={state.jsonText}
rows="6"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
></textarea>
{#if !jsonValid}
<span class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{:else}
<span class="hint">{$t('redeployTriggers.form.configJsonHint')}</span>
{/if}
</label>
{:else if state.kind === 'registry'}
<label class="sub" for="{idPrefix}-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="{idPrefix}-image"
type="text"
class="input mono"
bind:value={state.regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="{idPrefix}-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="{idPrefix}-tag"
type="text"
class="input mono"
bind:value={state.regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if state.kind === 'git'}
<label class="sub" for="{idPrefix}-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="{idPrefix}-repo"
type="text"
class="input mono"
bind:value={state.gitRepo}
placeholder={$t('redeployTriggers.form.repoPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
</label>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.mode')}</span>
<div
class="mode-row"
role="radiogroup"
aria-label={$t('redeployTriggers.form.mode')}
>
<button
type="button"
role="radio"
aria-checked={state.gitMode === 'push'}
class="mode-chip"
class:active={state.gitMode === 'push'}
onclick={() => (state.gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={state.gitMode === 'tag'}
class="mode-chip"
class:active={state.gitMode === 'tag'}
onclick={() => (state.gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if state.gitMode === 'push'}
<label class="sub" for="{idPrefix}-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="{idPrefix}-branch"
type="text"
class="input mono"
bind:value={state.gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="{idPrefix}-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="{idPrefix}-gtag"
type="text"
class="input mono"
bind:value={state.gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if state.kind === 'manual'}
<div class="note">
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</div>
{:else}
<div class="note">
<span class="note-tag">?</span>
<p>{$t('redeployTriggers.form.unknownNote')}</p>
</div>
{/if}
</fieldset>
{#if showWebhook}
<fieldset class="field group">
<legend class="sub-label legend-row">
<span>{$t('redeployTriggers.detail.webhook')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookEnabled')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookEnabledHint')}</p>
</div>
<ToggleSwitch
bind:checked={state.webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if state.webhookEnabled}
<div class="row-toggle indent">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookRequireSig')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookRequireSigHint')}</p>
</div>
<ToggleSwitch
bind:checked={state.webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
{/if}
</div>
<style>
.tk-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group {
gap: 0.6rem;
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.legend-row {
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.req,
.opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req {
color: var(--color-danger);
}
.opt {
color: var(--text-tertiary);
}
.adv-toggle {
margin-left: auto;
padding: 0.2rem 0.55rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
}
.adv-toggle:hover {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.adv-toggle.on {
background: var(--forge-accent);
border-color: var(--forge-accent);
color: var(--surface-card);
}
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono {
font-family: var(--forge-mono);
font-size: 0.85rem;
}
.input.code {
resize: vertical;
min-height: 110px;
line-height: 1.5;
}
.input.bad {
border-color: var(--color-danger);
}
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.hint {
font-size: 0.76rem;
color: var(--text-tertiary);
line-height: 1.45;
margin: 0;
}
.hint.danger {
color: var(--color-danger);
}
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 600px) {
.kind-grid {
grid-template-columns: 1fr;
}
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.8rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
position: relative;
}
.kind-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.kind-card-tag {
display: inline-flex;
align-self: flex-start;
padding: 0.18rem 0.5rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.68rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.kind-static {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 0.95rem;
color: var(--text-primary);
font-weight: 600;
}
.kind-tag {
display: inline-flex;
padding: 0.16rem 0.5rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.mode-row {
display: inline-flex;
gap: 0;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
padding: 2px;
width: fit-content;
}
.mode-chip {
padding: 0.28rem 0.8rem;
background: transparent;
border: 0;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.mode-chip:hover {
color: var(--text-primary);
}
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
.note {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.7rem 0.85rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.note-tag {
padding: 0.16rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
}
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.05rem;
padding-left: 0.95rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.lbl {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
}
.lbl.small {
font-size: 0.9rem;
}
</style>
+231
View File
@@ -17,6 +17,7 @@
"apps": "Apps", "apps": "Apps",
"eventTriggers": "Triggers", "eventTriggers": "Triggers",
"logScanRules": "Log Rules", "logScanRules": "Log Rules",
"triggers": "Triggers",
"projects": "Projects", "projects": "Projects",
"deploy": "Deploy", "deploy": "Deploy",
"proxies": "Proxies", "proxies": "Proxies",
@@ -1527,5 +1528,235 @@
"overriding": "Overriding…", "overriding": "Overriding…",
"overrideTitle": "Create a per-workload override of this global rule" "overrideTitle": "Create a per-workload override of this global rule"
} }
},
"redeployTriggers": {
"section": "The Forge",
"title": "Redeploy triggers",
"titleNew": "Forge a new trigger",
"titleSingular": "Trigger",
"lede": "Sources of redeploy signals — registry pushes, git events, manual fires, schedules, webhooks, log matches. Each trigger lives once and fans out to every workload bound to it.",
"ledeNew": "Pick a kind, name it, and decide whether external systems may poke it via webhook. Bind it to one or more workloads from the workload page after creation.",
"ledeDetail": "Edit the trigger config, manage its webhook ingress, and review every workload listening to this signal.",
"stat": {
"total": "TOTAL",
"byKind": "{kind}",
"withWebhook": "WEBHOOK ON",
"boundWorkloads": "WORKLOADS"
},
"kind": {
"registry": "Registry",
"git": "Git",
"manual": "Manual",
"schedule": "Schedule",
"webhook": "Webhook",
"logscan": "Log scan",
"unknown": "Unknown"
},
"kindShort": {
"registry": "REG",
"git": "GIT",
"manual": "MAN",
"schedule": "CRN",
"webhook": "HK",
"logscan": "LOG",
"unknown": "?"
},
"kindHint": {
"registry": "Watch a container image; fire when a new tag matching the pattern is pushed.",
"git": "Fire when a configured branch advances or a tag matching the pattern is created.",
"manual": "Fires only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"schedule": "Fires on a fixed cron-style schedule.",
"webhook": "Pure webhook — fires when the ingress URL is hit.",
"logscan": "Fires when an upstream log-scan rule matches a tailed line.",
"unknown": "Unknown trigger kind — fall back to the raw JSON editor."
},
"toolbar": {
"newButton": "New trigger",
"backToList": "Back to triggers"
},
"filter": {
"all": "ALL",
"ariaLabel": "Filter by kind"
},
"empty": {
"heading": "No triggers yet",
"body": "A trigger is the source of a redeploy signal — a registry watcher, git hook, manual button, scheduled fire, or webhook. Create one and bind it to as many workloads as you like.",
"cta": "Forge the first trigger"
},
"list": {
"name": "Name",
"kind": "Kind",
"bindings": "Workloads",
"webhook": "Webhook",
"created": "Created",
"open": "Open",
"webhookOn": "ON",
"webhookOff": "—",
"noBindings": "—",
"bindingsCount": "{count}"
},
"detail": {
"config": "Trigger configuration",
"configSub": "kind {kind} · id {id} · updated {updatedAt}",
"webhook": "Webhook ingress",
"webhookSub": "When enabled, external systems can fire this trigger by posting to the URL below. Each workload bound to it will be redeployed in turn.",
"webhookEnable": "Enable webhook ingress",
"webhookEnableHint": "When off, the trigger fires only via internal sources (its kind config) and the manual deploy button.",
"webhookRequireSig": "Require HMAC signature",
"webhookRequireSigHint": "Reject requests without a valid X-Hub-Signature-256 header. Recommended whenever the URL is reachable from the public internet.",
"webhookUrlLabel": "Ingress URL",
"webhookUrlNote": "Paste this into your CI / registry / GitHub webhook settings. The secret segment is the bearer — treat it like a password.",
"webhookCopy": "Copy",
"webhookCopied": "Copied",
"webhookRotate": "Rotate secret",
"webhookRotating": "Rotating…",
"webhookDisabledNote": "Webhook ingress is disabled. Toggle it on, save, and the URL will appear here.",
"bindings": "Bound workloads",
"bindingsSub": "Every workload listening to this trigger. To bind a new workload, open the workload page and add this trigger from there.",
"bindingsEmpty": "No workloads are bound to this trigger yet. Open a workload and bind this trigger from its Triggers panel.",
"bindingsListItem": {
"openWorkload": "Open workload",
"unbind": "Unbind"
},
"bindingEnabledHint": "Disable to keep the binding but stop this trigger from redeploying that workload.",
"dangerZone": "Danger zone",
"dangerZoneSub": "Trigger deletion is immediate. All bindings to it are cascade-removed.",
"deleteButton": "Delete trigger",
"deleteTitle": "Delete trigger?",
"deleteMessage": "Trigger \"{name}\" will be removed immediately, along with {count} binding(s). This cannot be undone.",
"rotateTitle": "Rotate webhook secret?",
"rotateMessage": "The current ingress URL stops working immediately. Update every external integration with the new URL after rotation.",
"rotateConfirm": "Rotate now",
"unbindTitle": "Unbind workload?",
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
"unbindConfirm": "Unbind"
},
"form": {
"kindLabel": "Kind",
"kindHint": "Pick the source of the redeploy signal. The form below adapts to the kind.",
"name": "Name",
"namePlaceholder": "e.g. ghcr.io/me/api · main",
"required": "REQUIRED",
"configLabel": "Configuration",
"image": "Image reference",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.",
"tagPattern": "Tag pattern",
"tagPatternPlaceholder": "*",
"tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.",
"repo": "Repository",
"repoPlaceholder": "owner/name",
"repoHint": "Provider-agnostic owner/name slug as advertised by the git host.",
"mode": "Mode",
"modePush": "Push to branch",
"modeTag": "Tag created",
"branch": "Branch",
"branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.",
"advancedToggle": "Advanced JSON",
"advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.",
"configJson": "Config JSON",
"configJsonHint": "Must parse as a valid JSON object. The shape is validated server-side per kind.",
"invalidJson": "Invalid JSON — server will reject.",
"webhookEnabled": "Enable webhook ingress now",
"webhookEnabledHint": "Generates a secret URL that external systems can hit to fire the trigger.",
"webhookRequireSig": "Require HMAC signature",
"webhookRequireSigHint": "Reject unsigned requests. The secret is the same one embedded in the URL — sign the body with HMAC-SHA256 and send it as X-Hub-Signature-256.",
"submit": "Forge trigger",
"submitting": "Forging…",
"cancel": "Cancel"
},
"binding": {
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"apps": {
"new": {
"triggers": {
"section": "Trigger",
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
"modeInline": "Add a trigger",
"modeInlineHint": "Creates a brand-new trigger record bound to this app — fits the common 1:1 case.",
"modePick": "Pick existing trigger",
"modePickHint": "Bind an existing trigger so multiple apps share one signal.",
"modeSkip": "Skip — add later",
"modeSkipHint": "The app is created without any trigger binding. Manual deploys still work.",
"switchToPick": "Pick existing trigger →",
"switchToInline": "← Create a new trigger instead",
"switchToSkip": "Skip for now",
"pickPlaceholder": "Select a trigger…",
"pickEmpty": "No triggers exist yet — create one inline above, or visit /triggers.",
"pickLabel": "Existing trigger",
"pickHint": "The same trigger can be bound to many apps. Manage standalone triggers under /triggers.",
"pickWebhookOn": "WEBHOOK ON",
"skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.",
"bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry."
}
},
"detail": {
"manualDeploySub": "Bypasses configured triggers and dispatches through the source plugin directly.",
"chainTriggersZero": "no triggers",
"chainTriggersOne": "1 trigger",
"chainTriggersMany": "{count} triggers",
"bindings": {
"title": "Triggers",
"subEmpty": "No triggers bound. Manual deploys still work — add a trigger to wire up registry / git / webhook redeploys.",
"subCount": "{count} trigger bound",
"subCountMany": "{count} triggers bound",
"addButton": "Add trigger",
"openTrigger": "View trigger",
"unbindAction": "Unbind",
"rowEnabled": "Enabled",
"rowDisabled": "Disabled",
"rowEnableHint": "Disable to keep the binding but stop this trigger from redeploying the app.",
"loading": "Loading triggers…",
"loadError": "Failed to load trigger bindings",
"unbindTitle": "Unbind trigger?",
"unbindMessage": "Trigger \"{name}\" will stop redeploying this app. The trigger record itself is not deleted — it stays in /triggers and remains bound to any other apps.",
"unbindConfirm": "Unbind",
"modal": {
"title": "Add trigger",
"subtitle": "Bind a trigger to this app — create a new one inline, or pick an existing trigger to share.",
"tabInline": "Create new",
"tabPick": "Bind existing",
"submitInline": "Create & bind",
"submitPick": "Bind",
"submitting": "Binding…",
"cancel": "Cancel",
"error": "Bind failed",
"pickPlaceholder": "Select a trigger…",
"pickEmpty": "No triggers exist yet — switch to \"Create new\" to make one.",
"pickLabel": "Existing trigger",
"pickKind": "Filter by kind",
"pickKindAll": "All kinds"
},
"override": {
"toggle": "Override",
"title": "Per-binding overrides",
"subtitle": "Override fields of the trigger's config for this app only. Top-level keys you set here win; everything else inherits from the trigger.",
"badgeOne": "OVERRIDES 1 FIELD",
"badgeMany": "OVERRIDES {count} FIELDS",
"badgeTitle": "This binding overrides one or more fields of the trigger's config.",
"baseLabel": "Trigger config",
"baseLoading": "Loading trigger config…",
"baseHint": "Read-only view of the parent trigger's config. Edit it from the trigger page if it should change for every binding.",
"editLabel": "Override (JSON object)",
"editHint": "Top-level merge: only keys present here override the trigger. Leave the editor as {} to inherit verbatim.",
"previewLabel": "Effective config",
"previewHint": "Preview of what this binding will see when the trigger fires (trigger config with the override merged on top).",
"invalidJson": "Override must be a JSON object.",
"tooLarge": "Override is {size} B — exceeds the {limit} B server limit.",
"errInvalidJson": "Cannot save: override is not a valid JSON object.",
"errTooLarge": "Cannot save: override exceeds the 8 KiB server limit.",
"saveButton": "Save override",
"saving": "Saving…",
"resetButton": "Reset to inherit",
"closeButton": "Close"
}
}
}
} }
} }
+231
View File
@@ -17,6 +17,7 @@
"apps": "Приложения", "apps": "Приложения",
"eventTriggers": "Триггеры", "eventTriggers": "Триггеры",
"logScanRules": "Лог-правила", "logScanRules": "Лог-правила",
"triggers": "Триггеры",
"projects": "Проекты", "projects": "Проекты",
"deploy": "Деплой", "deploy": "Деплой",
"proxies": "Прокси", "proxies": "Прокси",
@@ -1527,5 +1528,235 @@
"overriding": "Переопределение…", "overriding": "Переопределение…",
"overrideTitle": "Создать переопределение глобального правила для этой нагрузки" "overrideTitle": "Создать переопределение глобального правила для этой нагрузки"
} }
},
"redeployTriggers": {
"section": "Кузница",
"title": "Триггеры передеплоя",
"titleNew": "Новый триггер",
"titleSingular": "Триггер",
"lede": "Источники сигналов передеплоя — push в registry, события git, ручной запуск, расписания, webhook'и, совпадения в логах. Триггер создаётся один раз и веером раздаёт сигнал всем привязанным к нему нагрузкам.",
"ledeNew": "Выберите вид, дайте имя и решите, могут ли внешние системы дёргать его через webhook. Привязку к нагрузкам делайте со страницы нагрузки после создания.",
"ledeDetail": "Редактируйте конфигурацию триггера, управляйте webhook-приёмом и просматривайте все нагрузки, слушающие этот сигнал.",
"stat": {
"total": "ВСЕГО",
"byKind": "{kind}",
"withWebhook": "С WEBHOOK",
"boundWorkloads": "НАГРУЗОК"
},
"kind": {
"registry": "Registry",
"git": "Git",
"manual": "Ручной",
"schedule": "Расписание",
"webhook": "Webhook",
"logscan": "Лог-скан",
"unknown": "Неизвестный"
},
"kindShort": {
"registry": "REG",
"git": "GIT",
"manual": "MAN",
"schedule": "CRN",
"webhook": "HK",
"logscan": "LOG",
"unknown": "?"
},
"kindHint": {
"registry": "Следит за образом контейнера; срабатывает при push нового тега, подходящего под шаблон.",
"git": "Срабатывает при продвижении указанной ветки или создании тега, подходящего под шаблон.",
"manual": "Срабатывает только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"schedule": "Срабатывает по фиксированному cron-расписанию.",
"webhook": "Чистый webhook — срабатывает при обращении к URL приёма.",
"logscan": "Срабатывает, когда правило сканирования логов совпадает со строкой.",
"unknown": "Неизвестный вид триггера — используйте сырой JSON-редактор."
},
"toolbar": {
"newButton": "Новый триггер",
"backToList": "К списку триггеров"
},
"filter": {
"all": "ВСЕ",
"ariaLabel": "Фильтр по виду"
},
"empty": {
"heading": "Триггеров пока нет",
"body": "Триггер — источник сигнала передеплоя: registry-watcher, git-hook, ручная кнопка, расписание или webhook. Создайте один и привяжите к скольким угодно нагрузкам.",
"cta": "Создать первый триггер"
},
"list": {
"name": "Имя",
"kind": "Вид",
"bindings": "Нагрузки",
"webhook": "Webhook",
"created": "Создан",
"open": "Открыть",
"webhookOn": "ВКЛ",
"webhookOff": "—",
"noBindings": "—",
"bindingsCount": "{count}"
},
"detail": {
"config": "Конфигурация триггера",
"configSub": "вид {kind} · id {id} · обновлено {updatedAt}",
"webhook": "Webhook-приём",
"webhookSub": "Когда включено, внешние системы могут дёргать триггер по URL ниже. Каждая привязанная нагрузка будет передеплоена по очереди.",
"webhookEnable": "Включить webhook-приём",
"webhookEnableHint": "Когда выключено, триггер срабатывает только из внутренних источников (по конфигу его вида) и кнопки ручного деплоя.",
"webhookRequireSig": "Требовать HMAC-подпись",
"webhookRequireSigHint": "Отклонять запросы без корректного X-Hub-Signature-256. Рекомендуется, если URL доступен из публичной сети.",
"webhookUrlLabel": "URL приёма",
"webhookUrlNote": "Вставьте это в настройки CI / registry / webhook GitHub. Сегмент-секрет — это пароль, обращайтесь как с паролем.",
"webhookCopy": "Копировать",
"webhookCopied": "Скопировано",
"webhookRotate": "Сменить секрет",
"webhookRotating": "Смена…",
"webhookDisabledNote": "Webhook-приём выключен. Включите тумблер, сохраните — и URL появится здесь.",
"bindings": "Привязанные нагрузки",
"bindingsSub": "Все нагрузки, слушающие этот триггер. Чтобы привязать новую нагрузку, откройте её страницу и добавьте этот триггер оттуда.",
"bindingsEmpty": "К этому триггеру пока не привязана ни одна нагрузка. Откройте нагрузку и привяжите этот триггер из её панели «Триггеры».",
"bindingsListItem": {
"openWorkload": "Открыть нагрузку",
"unbind": "Отвязать"
},
"bindingEnabledHint": "Выключите, чтобы оставить привязку, но запретить триггеру передеплоить эту нагрузку.",
"dangerZone": "Опасная зона",
"dangerZoneSub": "Удаление триггера происходит сразу. Все привязки к нему удаляются каскадом.",
"deleteButton": "Удалить триггер",
"deleteTitle": "Удалить триггер?",
"deleteMessage": "Триггер «{name}» будет удалён немедленно вместе с {count} привязкой(-ами). Действие необратимо.",
"rotateTitle": "Сменить секрет webhook?",
"rotateMessage": "Текущий URL приёма перестанет работать сразу. После смены обновите URL во всех внешних интеграциях.",
"rotateConfirm": "Сменить",
"unbindTitle": "Отвязать нагрузку?",
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
"unbindConfirm": "Отвязать"
},
"form": {
"kindLabel": "Вид",
"kindHint": "Выберите источник сигнала передеплоя. Форма ниже подстраивается под вид.",
"name": "Имя",
"namePlaceholder": "например, ghcr.io/me/api · main",
"required": "ОБЯЗАТЕЛЬНО",
"configLabel": "Конфигурация",
"image": "Ссылка на образ",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
"tagPattern": "Шаблон тега",
"tagPatternPlaceholder": "*",
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
"repo": "Репозиторий",
"repoPlaceholder": "owner/name",
"repoHint": "owner/name в формате git-хостинга, не зависит от провайдера.",
"mode": "Режим",
"modePush": "Push в ветку",
"modeTag": "Создание тега",
"branch": "Ветка",
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.",
"advancedToggle": "Расширенный JSON",
"advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.",
"configJson": "JSON конфигурации",
"configJsonHint": "Должен распарситься как корректный JSON-объект. Структура проверяется сервером по виду.",
"invalidJson": "Некорректный JSON — сервер отклонит.",
"webhookEnabled": "Включить webhook-приём сразу",
"webhookEnabledHint": "Генерирует секретный URL, по которому внешние системы могут дёргать триггер.",
"webhookRequireSig": "Требовать HMAC-подпись",
"webhookRequireSigHint": "Отклонять неподписанные запросы. Секрет — тот же, что вшит в URL — подпишите тело HMAC-SHA256 и пришлите в X-Hub-Signature-256.",
"submit": "Создать триггер",
"submitting": "Создание…",
"cancel": "Отмена"
},
"binding": {
"enabled": "Включена",
"disabled": "Выключена"
}
},
"apps": {
"new": {
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
"modeInline": "Создать триггер",
"modeInlineHint": "Создаёт новую запись триггера, привязанную к этому приложению — подходит для частого случая 1:1.",
"modePick": "Выбрать существующий",
"modePickHint": "Привязать существующий триггер, чтобы несколько приложений делили один сигнал.",
"modeSkip": "Пропустить — добавить позже",
"modeSkipHint": "Приложение создаётся без привязки триггера. Ручной деплой по-прежнему работает.",
"switchToPick": "Выбрать существующий →",
"switchToInline": "← Создать новый триггер",
"switchToSkip": "Пропустить",
"pickPlaceholder": "Выберите триггер…",
"pickEmpty": "Триггеров ещё нет — создайте один выше или перейдите в /triggers.",
"pickLabel": "Существующий триггер",
"pickHint": "Один триггер можно привязать к нескольким приложениям. Управление автономными триггерами — в /triggers.",
"pickWebhookOn": "ВЕБХУК ВКЛ",
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
}
},
"detail": {
"manualDeploySub": "Обходит настроенные триггеры и отправляет деплой напрямую через source-плагин.",
"chainTriggersZero": "без триггеров",
"chainTriggersOne": "1 триггер",
"chainTriggersMany": "{count} триггер(ов)",
"bindings": {
"title": "Триггеры",
"subEmpty": "Триггеры не привязаны. Ручной деплой работает — добавьте триггер, чтобы подключить передеплой по реестру / git / вебхуку.",
"subCount": "{count} привязанный триггер",
"subCountMany": "{count} привязанных триггеров",
"addButton": "Добавить триггер",
"openTrigger": "Открыть триггер",
"unbindAction": "Отвязать",
"rowEnabled": "Включён",
"rowDisabled": "Выключен",
"rowEnableHint": "Отключите, чтобы сохранить привязку, но остановить передеплой этого приложения.",
"loading": "Загрузка триггеров…",
"loadError": "Не удалось загрузить привязки триггеров",
"unbindTitle": "Отвязать триггер?",
"unbindMessage": "Триггер «{name}» перестанет передеплоить это приложение. Сам триггер не удаляется — он остаётся в /triggers и сохраняет привязки к другим приложениям.",
"unbindConfirm": "Отвязать",
"modal": {
"title": "Добавить триггер",
"subtitle": "Привяжите триггер к этому приложению — создайте новый или выберите существующий, чтобы делить его.",
"tabInline": "Создать новый",
"tabPick": "Выбрать существующий",
"submitInline": "Создать и привязать",
"submitPick": "Привязать",
"submitting": "Привязка…",
"cancel": "Отмена",
"error": "Не удалось привязать",
"pickPlaceholder": "Выберите триггер…",
"pickEmpty": "Триггеров ещё нет — переключитесь на «Создать новый», чтобы добавить.",
"pickLabel": "Существующий триггер",
"pickKind": "Фильтр по виду",
"pickKindAll": "Все виды"
},
"override": {
"toggle": "Переопределить",
"title": "Переопределения привязки",
"subtitle": "Переопределите поля конфига триггера только для этого приложения. Верхнеуровневые ключи отсюда побеждают; остальное наследуется из триггера.",
"badgeOne": "ПЕРЕОПРЕДЕЛЕНО: 1 ПОЛЕ",
"badgeMany": "ПЕРЕОПРЕДЕЛЕНО ПОЛЕЙ: {count}",
"badgeTitle": "Эта привязка переопределяет одно или несколько полей конфига триггера.",
"baseLabel": "Конфиг триггера",
"baseLoading": "Загрузка конфига триггера…",
"baseHint": "Конфиг родительского триггера в режиме чтения. Редактируйте его на странице триггера, если изменения нужны для всех привязок.",
"editLabel": "Переопределение (JSON-объект)",
"editHint": "Слияние по верхнему уровню: переопределяются только указанные здесь ключи. Оставьте {} — будет наследоваться без изменений.",
"previewLabel": "Итоговый конфиг",
"previewHint": "Предпросмотр того, что увидит эта привязка при срабатывании триггера (конфиг триггера + наложенное переопределение).",
"invalidJson": "Переопределение должно быть JSON-объектом.",
"tooLarge": "Размер переопределения — {size} Б, превышает серверный лимит {limit} Б.",
"errInvalidJson": "Нельзя сохранить: переопределение не является валидным JSON-объектом.",
"errTooLarge": "Нельзя сохранить: переопределение превышает серверный лимит 8 КиБ.",
"saveButton": "Сохранить переопределение",
"saving": "Сохранение…",
"resetButton": "Сбросить к наследованию",
"closeButton": "Закрыть"
}
}
}
} }
} }
+2 -1
View File
@@ -44,7 +44,8 @@
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' }, { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true }, { href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Triggers' }, { href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' },
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' }, { href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' } { href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
]; ];
File diff suppressed because it is too large Load Diff
+305 -117
View File
@@ -2,19 +2,43 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { HookKinds, PluginWorkloadInput } from '$lib/types'; import type { HookKinds, PluginWorkloadInput } from '$lib/types';
import type { RedeployTrigger } from '$lib/api';
import * as api from '$lib/api'; import * as api from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
buildTriggerInput
} from '$lib/components/TriggerKindForm.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// `triggers` is no longer hardcoded into the workload row — the
// kind list is kept on `kinds` only for reference but the wizard
// now composes a standalone Trigger record (or picks one) and
// binds it after the workload is created.
let kinds = $state<HookKinds>({ sources: [], triggers: [] }); let kinds = $state<HookKinds>({ sources: [], triggers: [] });
let name = $state(''); let name = $state('');
let sourceKind = $state('image'); let sourceKind = $state('image');
let triggerKind = $state('manual');
let sourceConfig = $state('{}'); let sourceConfig = $state('{}');
let triggerConfig = $state('{}');
let publicSubdomain = $state(''); let publicSubdomain = $state('');
let publicDomain = $state(''); let publicDomain = $state('');
let publicPort = $state(0); let publicPort = $state(0);
// Trigger UX modes — three branches:
// inline → create a new trigger inline; bind after workload create.
// pick → select an existing trigger; bind after workload create.
// skip → create the workload without any binding.
type TriggerMode = 'inline' | 'pick' | 'skip';
let triggerMode = $state<TriggerMode>('inline');
let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
// Existing-trigger picker. `existingTriggers` is loaded lazily on
// mount; if the request fails the operator can still create one
// inline or skip altogether — we don't block the wizard.
let existingTriggers = $state<RedeployTrigger[]>([]);
let pickedTriggerId = $state('');
// Kind-aware compose editor — the raw-JSON textarea forces users to // Kind-aware compose editor — the raw-JSON textarea forces users to
// hand-escape YAML inside a JSON string, which is unusable. When the // hand-escape YAML inside a JSON string, which is unusable. When the
// source plugin is "compose" we surface a dedicated YAML textarea and // source plugin is "compose" we surface a dedicated YAML textarea and
@@ -79,7 +103,7 @@
schemaCache.set(kind, text); schemaCache.set(kind, text);
return text; return text;
} catch { } catch {
return sourceConfigSample(kind) || triggerConfigSample(kind) || '{}'; return sourceConfigSample(kind) || '{}';
} }
} }
@@ -274,11 +298,7 @@
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) { if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
sourceKind = kinds.sources[0]; sourceKind = kinds.sources[0];
} }
if (kinds.triggers.length > 0 && !kinds.triggers.includes(triggerKind)) {
triggerKind = kinds.triggers[0];
}
sourceConfig = await fetchSampleJSON(sourceKind); sourceConfig = await fetchSampleJSON(sourceKind);
triggerConfig = await fetchSampleJSON(triggerKind);
if (sourceKind === 'compose') { if (sourceKind === 'compose') {
seedComposeFromJSON(sourceConfig); seedComposeFromJSON(sourceConfig);
} }
@@ -296,6 +316,14 @@
} catch { } catch {
registries = []; registries = [];
} }
// Best-effort fetch of existing triggers — feeds the
// "Pick existing" mode. Failure leaves the picker empty
// and the operator can still create one inline.
try {
existingTriggers = await api.listTriggers();
} catch {
existingTriggers = [];
}
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Failed to load plugin kinds'; error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
} finally { } finally {
@@ -350,26 +378,6 @@
} }
} }
function triggerConfigSample(kind: string): string {
switch (kind) {
case 'registry':
return JSON.stringify(
{ image: 'registry.example.com/owner/app', tag_pattern: 'v*' },
null,
2
);
case 'git':
return JSON.stringify(
{ repo: 'owner/repo', mode: 'push', branch: 'main', tag_pattern: '' },
null,
2
);
case 'manual':
default:
return '{}';
}
}
async function onSourceChange() { async function onSourceChange() {
sourceConfig = await fetchSampleJSON(sourceKind); sourceConfig = await fetchSampleJSON(sourceKind);
// Switching INTO compose / image seeds the form fields from // Switching INTO compose / image seeds the form fields from
@@ -389,9 +397,6 @@
advancedJson = false; advancedJson = false;
} }
} }
async function onTriggerChange() {
triggerConfig = await fetchSampleJSON(triggerKind);
}
// Toggle between the kind-aware form and the raw JSON editor. // Toggle between the kind-aware form and the raw JSON editor.
// Direction matters: going to Advanced JSON commits the form fields // Direction matters: going to Advanced JSON commits the form fields
@@ -423,12 +428,17 @@
} }
} }
const sourceValid = $derived(jsonOk(sourceConfig)); const sourceValid = $derived(jsonOk(sourceConfig));
const triggerValid = $derived(jsonOk(triggerConfig));
const sourceLines = $derived(sourceConfig.split('\n').length); const sourceLines = $derived(sourceConfig.split('\n').length);
const sourceBytes = $derived(new Blob([sourceConfig]).size); const sourceBytes = $derived(new Blob([sourceConfig]).size);
const triggerLines = $derived(triggerConfig.split('\n').length);
const triggerBytes = $derived(new Blob([triggerConfig]).size); // Trigger-step validity. Inline mode requires a complete kind+name+config;
// pick mode requires a chosen trigger; skip mode is always valid.
const triggerStepValid = $derived.by(() => {
if (triggerMode === 'skip') return true;
if (triggerMode === 'pick') return !!pickedTriggerId;
return isTriggerFormValid(triggerForm);
});
async function submit(e: Event) { async function submit(e: Event) {
e.preventDefault(); e.preventDefault();
@@ -436,7 +446,6 @@
submitting = true; submitting = true;
try { try {
let parsedSource: unknown; let parsedSource: unknown;
let parsedTrigger: unknown;
if (useComposeForm) { if (useComposeForm) {
// Form fields are typed primitives — no parse step // Form fields are typed primitives — no parse step
// needed. compose_yaml passes through verbatim; the // needed. compose_yaml passes through verbatim; the
@@ -459,18 +468,17 @@
throw new Error('source_config is not valid JSON'); throw new Error('source_config is not valid JSON');
} }
} }
try {
parsedTrigger = JSON.parse(triggerConfig);
} catch {
throw new Error('trigger_config is not valid JSON');
}
// Triggers no longer ride on the workload row; the backend
// still accepts the legacy fields but the new code path
// passes a manual placeholder + empty config and binds a
// real Trigger record after creation.
const body: PluginWorkloadInput = { const body: PluginWorkloadInput = {
name: name.trim(), name: name.trim(),
source_kind: sourceKind, source_kind: sourceKind,
source_config: parsedSource, source_config: parsedSource,
trigger_kind: triggerKind, trigger_kind: '',
trigger_config: parsedTrigger trigger_config: {}
}; };
if (publicSubdomain || publicDomain || publicPort > 0) { if (publicSubdomain || publicDomain || publicPort > 0) {
body.public_faces = [ body.public_faces = [
@@ -486,6 +494,35 @@
} }
const created = await api.createPluginWorkload(body); const created = await api.createPluginWorkload(body);
// Bind a trigger to the freshly-created workload. Keep going
// to the detail page even on bind failure — the operator can
// retry from the workload's Triggers panel without losing
// their work.
if (triggerMode === 'inline' || triggerMode === 'pick') {
try {
if (triggerMode === 'inline') {
const inline = buildTriggerInput(triggerForm);
await api.bindTriggerToWorkload(created.id, { inline });
} else if (pickedTriggerId) {
await api.bindTriggerToWorkload(created.id, {
trigger_id: pickedTriggerId
});
}
} catch (be) {
const msg = be instanceof Error ? be.message : 'unknown';
// Surface the bind failure to the user, then still
// route to the detail page where they can retry.
try {
sessionStorage.setItem(
`tinyforge.bindError.${created.id}`,
$t('apps.new.triggers.bindError', { error: msg })
);
} catch {
// session storage may be disabled — ignore.
}
}
}
goto(`/apps/${created.id}`); goto(`/apps/${created.id}`);
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Create failed'; error = e instanceof Error ? e.message : 'Create failed';
@@ -502,8 +539,7 @@
<div class="forge"> <div class="forge">
{#snippet newLede()} {#snippet newLede()}
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static). Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
<em>Trigger</em> = when it redeploys (registry push, git push, manual). Both axes are Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
independently extensible.
{/snippet} {/snippet}
<ForgeHero <ForgeHero
@@ -553,39 +589,25 @@
<div class="field"> <div class="field">
<div class="field-label"> <div class="field-label">
<span class="num">02</span> <span class="num">02</span>
<span class="lbl">Plugins</span> <span class="lbl">Source plugin</span>
<span class="opt">SOURCE × TRIGGER</span> <span class="opt">REQUIRED</span>
</div>
<div class="row">
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<select
id="app-source"
class="input"
bind:value={sourceKind}
onchange={onSourceChange}
>
{#each kinds.sources as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<label class="sub" for="app-trigger">
<span class="sub-label">Trigger</span>
<select
id="app-trigger"
class="input"
bind:value={triggerKind}
onchange={onTriggerChange}
>
{#each kinds.triggers as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
</div> </div>
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<select
id="app-source"
class="input"
bind:value={sourceKind}
onchange={onSourceChange}
>
{#each kinds.sources as k}
<option value={k}>{k}</option>
{/each}
</select>
</label>
<p class="hint"> <p class="hint">
Both pickers are populated from the running daemon — only plugins compiled in show up. Populated from the running daemon — only plugins compiled in show up. Triggers
(registry / git / manual) are configured below as standalone records.
</p> </p>
</div> </div>
@@ -921,10 +943,10 @@
</span> </span>
</label> </label>
</fieldset> </fieldset>
<label class="checkbox-row"> <label class="toggle-row">
<input <ToggleSwitch
type="checkbox"
bind:checked={staticRenderMarkdown} bind:checked={staticRenderMarkdown}
label="Render markdown"
/> />
<span> <span>
<strong>Render markdown</strong> — auto-render <code>.md</code> <strong>Render markdown</strong> — auto-render <code>.md</code>
@@ -982,45 +1004,108 @@
{/if} {/if}
</div> </div>
<div class="field"> <fieldset class="field group">
<div class="field-label"> <legend class="field-label as-legend">
<span class="num">04</span> <span class="num">04</span>
<span class="lbl">Trigger config</span> <span class="lbl">{$t('apps.new.triggers.section')}</span>
<span class="req">JSON</span> <span class="opt">OPTIONAL</span>
</legend>
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
<!-- Mode selector — three short cards. The "active" card
reveals its sub-form below. Skipping is explicit so
users can ship the workload now and wire triggers
later from the detail page. -->
<div
class="trig-mode-row"
role="radiogroup"
aria-label={$t('apps.new.triggers.section')}
>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'inline'}
class="trig-mode-card"
class:active={triggerMode === 'inline'}
onclick={() => (triggerMode = 'inline')}
>
<span class="trig-mode-tag mono">NEW</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'pick'}
class="trig-mode-card"
class:active={triggerMode === 'pick'}
onclick={() => (triggerMode = 'pick')}
>
<span class="trig-mode-tag mono">PICK</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={triggerMode === 'skip'}
class="trig-mode-card"
class:active={triggerMode === 'skip'}
onclick={() => (triggerMode = 'skip')}
>
<span class="trig-mode-tag mono">SKIP</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
</button>
</div> </div>
<div class="editor">
<div class="editor-head"> {#if triggerMode === 'inline'}
<span class="dot"></span><span class="dot"></span><span class="dot"></span> <div class="trig-sub">
<span class="editor-title">trigger_config.json · {triggerKind}</span> <TriggerKindForm
<span class="spacer"></span> bind:state={triggerForm}
<button idPrefix="app-trig"
type="button" showName={true}
class="editor-chip" showWebhook={true}
onclick={() => (triggerConfig = triggerConfigSample(triggerKind))} showKindPicker={true}
> />
Reset sample
</button>
</div> </div>
<textarea {:else if triggerMode === 'pick'}
id="app-trigger-config" <div class="trig-sub">
bind:value={triggerConfig} {#if existingTriggers.length === 0}
rows="7" <div class="note muted-note">
spellcheck="false" <span class="note-tag"></span>
class="code-area" <p>{$t('apps.new.triggers.pickEmpty')}</p>
aria-label="Trigger plugin configuration (JSON)" </div>
></textarea> {:else}
<div class="editor-foot"> <label class="sub" for="app-trig-pick">
<span class="foot-status" class:bad={!triggerValid}> <span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
<span class="foot-dot" aria-hidden="true"></span> <select
{triggerValid ? 'JSON OK' : 'JSON INVALID'} id="app-trig-pick"
</span> class="input"
<span class="sep">·</span> bind:value={pickedTriggerId}
<span>{triggerLines} lines</span> >
<span class="sep">·</span> <option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
<span>{triggerBytes} B</span> {#each existingTriggers as tr (tr.id)}
<option value={tr.id}>
{tr.name} · {tr.kind}{tr.webhook_enabled
? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
: ''}
</option>
{/each}
</select>
<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
</label>
{/if}
</div> </div>
</div> {:else}
</div> <div class="trig-sub">
<div class="note muted-note">
<span class="note-tag">SKIP</span>
<p>{$t('apps.new.triggers.skippedNote')}</p>
</div>
</div>
{/if}
</fieldset>
<fieldset class="field group"> <fieldset class="field group">
<legend class="field-label as-legend"> <legend class="field-label as-legend">
@@ -1074,7 +1159,7 @@
<button <button
class="btn-primary" class="btn-primary"
type="submit" type="submit"
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerValid} disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
> >
<span>{submitting ? 'Forging…' : 'Forge app'}</span> <span>{submitting ? 'Forging…' : 'Forge app'}</span>
<span class="arrow" aria-hidden="true"></span> <span class="arrow" aria-hidden="true"></span>
@@ -1553,16 +1638,15 @@
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
} }
.radio input, .radio input {
.checkbox-row input {
margin-top: 0.18rem; margin-top: 0.18rem;
accent-color: var(--color-brand-500); accent-color: var(--color-brand-500);
} }
.radio strong, .radio strong,
.checkbox-row strong { .toggle-row strong {
color: var(--text-primary); color: var(--text-primary);
} }
.checkbox-row { .toggle-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.55rem; gap: 0.55rem;
@@ -1571,4 +1655,108 @@
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
} }
.toggle-row :global(.toggle-switch) {
margin-top: 0.1rem;
}
/* ── Trigger mode picker ──────────────────────────
Three short cards (NEW / PICK / SKIP). The active
card lights up its tag in brand colour and reveals
the matching sub-form below in a soft inset panel. */
.trig-mode-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
@media (max-width: 720px) {
.trig-mode-row {
grid-template-columns: 1fr;
}
}
.trig-mode-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.85rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease,
transform 150ms ease;
}
.trig-mode-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.trig-mode-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.trig-mode-tag {
display: inline-flex;
align-self: flex-start;
padding: 0.18rem 0.5rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.trig-mode-card.active .trig-mode-tag {
background: var(--forge-accent);
}
.trig-mode-name {
font-weight: 600;
font-size: 0.92rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.trig-mode-hint {
font-size: 0.7rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.trig-sub {
margin-top: 0.2rem;
padding: 0.95rem 1rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
}
.note {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.65rem 0.85rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.muted-note {
background: transparent;
}
.note-tag {
padding: 0.16rem 0.4rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
}
</style> </style>
+649
View File
@@ -0,0 +1,649 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as api from '$lib/api';
import type { RedeployTrigger } from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
// Known kinds drive the kind-aware form switch in /new and the
// filter chips here. Future kinds are tolerated: an unknown kind
// renders with a generic label + grey badge instead of dropping
// the row.
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
let triggers = $state<RedeployTrigger[]>([]);
let loading = $state(true);
let error = $state('');
let kindFilter = $state<'all' | KnownKind | string>('all');
const filtered = $derived(
kindFilter === 'all' ? triggers : triggers.filter((t) => t.kind === kindFilter)
);
const withWebhook = $derived(triggers.filter((t) => t.webhook_enabled).length);
const totalBindings = $derived(
triggers.reduce((sum, t) => sum + (t.binding_count ?? 0), 0)
);
// Group triggers by kind for the hero stat rail. Caps to first
// three kinds + a roll-up so the rail stays single-line on
// narrow screens; the chip filter row exposes the full breakdown.
const byKind = $derived.by(() => {
const acc: Record<string, number> = {};
for (const t of triggers) acc[t.kind] = (acc[t.kind] ?? 0) + 1;
return acc;
});
const presentKinds = $derived(Object.keys(byKind));
function kindLabel(k: string): string {
const key = `redeployTriggers.kind.${k}`;
const label = $t(key);
return label === key ? k : label;
}
function kindShort(k: string): string {
const key = `redeployTriggers.kindShort.${k}`;
const label = $t(key);
return label === key ? k.slice(0, 3).toUpperCase() : label;
}
function kindClass(k: string): string {
// CSS-only kind colour. Falls through to the neutral
// `kind-other` style for unknown kinds so the row still
// renders cleanly.
return KNOWN_KINDS.includes(k as KnownKind) ? `kind-${k}` : 'kind-other';
}
function fmtCreated(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit'
});
} catch {
return iso;
}
}
async function load(): Promise<void> {
loading = true;
error = '';
try {
triggers = await api.listTriggers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load triggers';
} finally {
loading = false;
}
}
onMount(load);
</script>
<svelte:head>
<title>{$t('redeployTriggers.title')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
{#snippet toolbar()}
<button
class="forge-btn-icon"
onclick={load}
aria-label={$t('observability.refresh')}
disabled={loading}
>
<IconRefresh size={16} />
</button>
<a href="/triggers/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('redeployTriggers.toolbar.newButton')}</span>
</a>
{/snippet}
{#snippet stats()}
<div>
<dt>{$t('redeployTriggers.stat.total')}</dt>
<dd>{loading ? '—' : String(triggers.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('redeployTriggers.stat.withWebhook')}</dt>
<dd class="accent">{loading ? '—' : String(withWebhook).padStart(2, '0')}</dd>
</div>
<div>
<dt>{$t('redeployTriggers.stat.boundWorkloads')}</dt>
<dd>{loading ? '—' : String(totalBindings).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet lede()}
{$t('redeployTriggers.lede')}
{/snippet}
<ForgeHero
eyebrowSuffix={$t('redeployTriggers.section').toUpperCase()}
title={$t('redeployTriggers.title')}
size="lg"
toolbar={toolbar}
lede_html={lede}
stats={stats}
/>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if !loading && triggers.length > 0}
<!-- Kind filter chips. ALL is always present; per-kind chips are
rendered only for kinds present in the result set so the
row stays scannable when the operator only uses two kinds. -->
<div class="filter-row" role="group" aria-label={$t('redeployTriggers.filter.ariaLabel')}>
<button
type="button"
class="chip"
class:active={kindFilter === 'all'}
aria-pressed={kindFilter === 'all'}
onclick={() => (kindFilter = 'all')}
>
<span class="chip-label">{$t('redeployTriggers.filter.all')}</span>
<span class="chip-count">{String(triggers.length).padStart(2, '0')}</span>
</button>
{#each presentKinds as k}
<button
type="button"
class="chip"
class:active={kindFilter === k}
aria-pressed={kindFilter === k}
onclick={() => (kindFilter = k)}
>
<span class="chip-label">{kindLabel(k)}</span>
<span class="chip-count">{String(byKind[k]).padStart(2, '0')}</span>
</button>
{/each}
</div>
{/if}
{#if loading}
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
{#each Array(3) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else if triggers.length === 0}
<div class="empty">
<div class="empty-mark" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<h2>{$t('redeployTriggers.empty.heading')}</h2>
<p>{$t('redeployTriggers.empty.body')}</p>
<a href="/triggers/new" class="forge-btn">
<IconPlus size={14} /><span>{$t('redeployTriggers.empty.cta')}</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('redeployTriggers.list.name')}</th>
<th>{$t('redeployTriggers.list.kind')}</th>
<th>{$t('redeployTriggers.list.bindings')}</th>
<th>{$t('redeployTriggers.list.webhook')}</th>
<th class="hide-md">{$t('redeployTriggers.list.created')}</th>
<th class="t-right">{$t('redeployTriggers.list.open')}</th>
</tr>
</thead>
<tbody>
{#each filtered as trig, i (trig.id)}
<tr>
<td>
<a class="row-link" href={`/triggers/${trig.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{trig.name}</span>
</a>
</td>
<td>
<span class="badge kind {kindClass(trig.kind)}">
<span class="kind-tag">{kindShort(trig.kind)}</span>
<span class="kind-name">{kindLabel(trig.kind)}</span>
</span>
</td>
<td>
{#if trig.binding_count > 0}
<span class="bindings-pill" title={String(trig.binding_count)}>
<span class="bp-num">{trig.binding_count}</span>
<span class="bp-bar" aria-hidden="true">
{#each Array(Math.min(trig.binding_count, 6)) as _}
<span></span>
{/each}
</span>
</span>
{:else}
<span class="muted mono small">{$t('redeployTriggers.list.noBindings')}</span>
{/if}
</td>
<td>
<span class="status" class:on={trig.webhook_enabled} class:off={!trig.webhook_enabled}>
<span class="status-dot" aria-hidden="true"></span>
{trig.webhook_enabled
? $t('redeployTriggers.list.webhookOn')
: $t('redeployTriggers.list.webhookOff')}
</span>
</td>
<td class="muted mono small hide-md">{fmtCreated(trig.created_at)}</td>
<td class="actions-cell">
<a class="row-action" href={`/triggers/${trig.id}`}>
{$t('observability.open')} <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1100px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Filter chips ──────────────────────────────── */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
cursor: pointer;
color: var(--text-secondary);
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
transform 150ms ease;
}
.chip:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.chip.active {
background: var(--text-primary);
color: var(--surface-card);
border-color: var(--text-primary);
}
.chip-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.chip-count {
font-family: var(--forge-mono);
font-size: 0.6rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 52px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex;
gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--forge-accent);
animation: ember 2.4s ease-in-out infinite;
}
@keyframes ember {
0%,
100% {
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
}
.empty h2 {
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.empty p {
color: var(--text-secondary);
margin: 0 auto 1.5rem;
font-size: 0.95rem;
max-width: 52ch;
line-height: 1.5;
}
/* ── Table ─────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: var(--surface-card);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-wrap :global(.forge-table) {
min-width: 720px;
}
.t-right {
text-align: right;
}
.actions-cell {
text-align: right;
}
@media (max-width: 820px) {
.hide-md {
display: none;
}
}
/* ── Row link / action ─────────────────────────── */
.row-link {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
color: var(--text-primary);
text-decoration: none;
transition: color 120ms ease;
}
.row-link:hover {
color: var(--forge-accent);
}
.row-link:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-ref {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.row-name {
font-weight: 600;
}
.row-action {
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--forge-accent);
text-decoration: none;
}
.row-action:hover {
color: var(--color-brand-500);
}
.arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
/* ── Kind badge ─────────────────────────────────
Two-segment pill: a tight monospace tag (REG, GIT…)
followed by the human-readable kind name. The tag
carries the colour so the eye can pick out the kind
even when the operator filters all rows down to two
kinds and the names line up.
*/
.badge.kind {
display: inline-flex;
align-items: stretch;
overflow: hidden;
border-radius: var(--radius-full);
border: 1px solid var(--border-primary);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.kind-tag {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.5rem;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--surface-card);
background: var(--text-primary);
}
.kind-name {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem 0.18rem 0.5rem;
}
/* Per-kind colour. The pattern matches the rest of the app:
coloured tag + soft-tinted body that reads in both themes. */
.badge.kind.kind-registry .kind-tag {
background: var(--color-brand-600);
}
.badge.kind.kind-registry {
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
border-color: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
color: var(--color-brand-600);
}
.badge.kind.kind-git .kind-tag {
background: #6b46c1;
}
.badge.kind.kind-git {
background: color-mix(in srgb, #8b5cf6 12%, transparent);
border-color: color-mix(in srgb, #8b5cf6 32%, transparent);
color: #6b46c1;
}
:global([data-theme='dark']) .badge.kind.kind-git {
color: #c4b5fd;
}
.badge.kind.kind-manual .kind-tag {
background: var(--text-secondary);
}
.badge.kind.kind-schedule .kind-tag {
background: var(--color-warning, #f59e0b);
}
.badge.kind.kind-schedule {
background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent);
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
color: var(--color-warning-dark, #b45309);
}
.badge.kind.kind-webhook .kind-tag {
background: var(--forge-accent);
}
.badge.kind.kind-webhook {
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
color: var(--forge-accent);
}
.badge.kind.kind-logscan .kind-tag {
background: #0891b2;
}
.badge.kind.kind-logscan {
background: color-mix(in srgb, #06b6d4 12%, transparent);
border-color: color-mix(in srgb, #06b6d4 32%, transparent);
color: #0e7490;
}
:global([data-theme='dark']) .badge.kind.kind-logscan {
color: #67e8f9;
}
/* ── Bindings pill ──────────────────────────────
Number + miniature segmented bar that visually
communicates fan-out without taking a whole column.
*/
.bindings-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.18rem 0.55rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
}
.bp-num {
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.bp-bar {
display: inline-flex;
gap: 2px;
}
.bp-bar span {
width: 4px;
height: 10px;
background: var(--forge-accent);
border-radius: 1px;
opacity: 0.85;
}
.bp-bar span:nth-child(2) { opacity: 0.7; }
.bp-bar span:nth-child(3) { opacity: 0.6; }
.bp-bar span:nth-child(4) { opacity: 0.5; }
.bp-bar span:nth-child(5) { opacity: 0.4; }
.bp-bar span:nth-child(6) { opacity: 0.3; }
.small {
font-size: 0.72rem;
}
/* ── Status (webhook on/off) ──────────────────── */
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status.on {
color: var(--color-success-dark);
}
.status.on .status-dot {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
}
.status.off {
color: var(--text-tertiary);
}
.status.off .status-dot {
background: var(--text-tertiary);
opacity: 0.5;
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
}
</style>
File diff suppressed because it is too large Load Diff
+767
View File
@@ -0,0 +1,767 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import type { TriggerInput } from '$lib/api';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n';
// Three kinds have hand-rolled forms today; anything else falls
// back to the JSON editor. KNOWN_KINDS gates the structured form
// switch — see formNote() for the manual/unknown explainer text.
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
let kind = $state<KnownKind | string>('registry');
let name = $state('');
let webhookEnabled = $state(false);
let webhookRequireSig = $state(true);
let useAdvancedJson = $state(false);
let submitting = $state(false);
let error = $state('');
// Per-kind structured fields. They mirror the Go config shapes
// documented in the parent task description — see TriggerInput
// in $lib/api. Keeping them as separate $state slots lets the
// kind switch persist values across kind flips (operator typo
// recovery) without juggling a discriminated union.
let regImage = $state('');
let regTagPattern = $state('*');
let gitRepo = $state('');
let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main');
let gitTagPattern = $state('v*');
// Advanced JSON editor — primed with the sample shape for the
// current kind on first toggle so the operator has something to
// edit. We only auto-prime when the field is blank to avoid
// nuking deliberate edits on re-toggle.
let jsonText = $state('');
let jsonLoading = $state(false);
const jsonValid = $derived.by(() => {
if (!useAdvancedJson) return true;
if (!jsonText.trim()) return true; // blank treated as empty object server-side
try {
const parsed = JSON.parse(jsonText);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
});
function buildConfig(): unknown {
if (useAdvancedJson) {
if (!jsonText.trim()) return {};
return JSON.parse(jsonText);
}
switch (kind) {
case 'registry':
return {
image: regImage.trim(),
tag_pattern: regTagPattern.trim() || '*'
};
case 'git':
return gitMode === 'push'
? { repo: gitRepo.trim(), mode: 'push', branch: gitBranch.trim() || 'main' }
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual':
return {};
default:
// Unknown kind reached the structured path — fall back
// to an empty object; advanced JSON would normally be
// on by this point.
return {};
}
}
function canSubmit(): boolean {
if (submitting) return false;
if (!name.trim()) return false;
if (useAdvancedJson) return jsonValid;
switch (kind) {
case 'registry':
return !!regImage.trim();
case 'git':
return !!gitRepo.trim();
case 'manual':
return true;
default:
return false; // unknown kinds force advanced JSON
}
}
async function loadSampleIntoJson(): Promise<void> {
jsonLoading = true;
try {
const schema = await api.getHookKindSchema(kind);
jsonText = JSON.stringify(schema.sample ?? {}, null, 2);
} catch {
// Best-effort prime — operator can paste their own.
jsonText = '{\n \n}';
} finally {
jsonLoading = false;
}
}
function toggleAdvanced(): void {
useAdvancedJson = !useAdvancedJson;
if (useAdvancedJson && !jsonText.trim()) {
// Seed with current structured values (or schema sample
// as fallback) so the operator can refine instead of
// retyping.
try {
jsonText = JSON.stringify(buildConfig(), null, 2);
} catch {
void loadSampleIntoJson();
}
}
}
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit()) return;
error = '';
submitting = true;
try {
const body: TriggerInput = {
kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
};
const created = await api.createTrigger(body);
goto(`/triggers/${created.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Create failed';
} finally {
submitting = false;
}
}
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
</script>
<svelte:head>
<title>{$t('redeployTriggers.titleNew')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet lede()}
{$t('redeployTriggers.ledeNew')}
{/snippet}
<ForgeHero
backHref="/triggers"
backLabel={$t('redeployTriggers.toolbar.backToList')}
eyebrowSuffix={$t('redeployTriggers.toolbar.newButton').toUpperCase()}
title={$t('redeployTriggers.titleNew')}
size="lg"
lede_html={lede}
/>
<form onsubmit={submit} class="form" novalidate aria-busy={submitting}>
{#if error}
<div class="alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
<!-- Step 01 · Kind picker. Renders as a grid of square cards
so the kind is the first visual commitment of the wizard. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">01</span>
<span class="lbl">{$t('redeployTriggers.form.kindLabel')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</legend>
<p class="hint">{$t('redeployTriggers.form.kindHint')}</p>
<div class="kind-grid" role="radiogroup" aria-label={$t('redeployTriggers.form.kindLabel')}>
{#each ALL_PICKABLE as k}
<button
type="button"
role="radio"
aria-checked={kind === k}
class="kind-card"
class:active={kind === k}
onclick={() => (kind = k)}
>
<span class="kind-card-tag mono">{$t(`redeployTriggers.kindShort.${k}`)}</span>
<span class="kind-card-name">{$t(`redeployTriggers.kind.${k}`)}</span>
<span class="kind-card-hint">{kindHint(k)}</span>
</button>
{/each}
</div>
</fieldset>
<!-- Step 02 · Name. -->
<div class="field">
<label for="trig-name" class="field-label">
<span class="num" aria-hidden="true">02</span>
<span class="lbl">{$t('redeployTriggers.form.name')}</span>
<span class="req">{$t('redeployTriggers.form.required')}</span>
</label>
<input
id="trig-name"
type="text"
bind:value={name}
class="input"
placeholder={$t('redeployTriggers.form.namePlaceholder')}
autocomplete="off"
required
/>
</div>
<!-- Step 03 · Config — kind-aware switch. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">03</span>
<span class="lbl">{$t('redeployTriggers.form.configLabel')}</span>
<span class="opt">{$t(`redeployTriggers.kindShort.${kind}`)}</span>
<button
type="button"
class="adv-toggle"
class:on={useAdvancedJson}
onclick={toggleAdvanced}
>
{$t('redeployTriggers.form.advancedToggle')}
</button>
</legend>
{#if useAdvancedJson}
<p class="hint">{$t('redeployTriggers.form.advancedHint')}</p>
<label class="sub" for="trig-json">
<span class="sub-label">{$t('redeployTriggers.form.configJson')}</span>
<textarea
id="trig-json"
class="input mono code"
class:bad={!jsonValid}
bind:value={jsonText}
rows="8"
spellcheck="false"
placeholder={'{ }'}
aria-invalid={!jsonValid}
aria-describedby={!jsonValid ? 'trig-json-err' : 'trig-json-hint'}
></textarea>
<span id="trig-json-hint" class="hint">
{$t('redeployTriggers.form.configJsonHint')}
{#if jsonLoading} <em>· loading sample…</em>{/if}
</span>
{#if !jsonValid}
<span id="trig-json-err" class="hint danger" role="alert">
{$t('redeployTriggers.form.invalidJson')}
</span>
{/if}
</label>
{:else if kind === 'registry'}
<label class="sub" for="trig-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="trig-image"
type="text"
class="input mono"
bind:value={regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="trig-tag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-tag"
type="text"
class="input mono"
bind:value={regTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{:else if kind === 'git'}
<label class="sub" for="trig-repo">
<span class="sub-label">{$t('redeployTriggers.form.repo')}</span>
<input
id="trig-repo"
type="text"
class="input mono"
bind:value={gitRepo}
placeholder={$t('redeployTriggers.form.repoPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
</label>
<div class="sub">
<span class="sub-label">{$t('redeployTriggers.form.mode')}</span>
<div class="mode-row" role="radiogroup" aria-label={$t('redeployTriggers.form.mode')}>
<button
type="button"
role="radio"
aria-checked={gitMode === 'push'}
class="mode-chip"
class:active={gitMode === 'push'}
onclick={() => (gitMode = 'push')}
>
{$t('redeployTriggers.form.modePush')}
</button>
<button
type="button"
role="radio"
aria-checked={gitMode === 'tag'}
class="mode-chip"
class:active={gitMode === 'tag'}
onclick={() => (gitMode = 'tag')}
>
{$t('redeployTriggers.form.modeTag')}
</button>
</div>
</div>
{#if gitMode === 'push'}
<label class="sub" for="trig-branch">
<span class="sub-label">{$t('redeployTriggers.form.branch')}</span>
<input
id="trig-branch"
type="text"
class="input mono"
bind:value={gitBranch}
placeholder={$t('redeployTriggers.form.branchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
{:else}
<label class="sub" for="trig-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
<input
id="trig-gtag"
type="text"
class="input mono"
bind:value={gitTagPattern}
placeholder={$t('redeployTriggers.form.tagPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
</label>
{/if}
{:else if kind === 'manual'}
<div class="note">
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</div>
{:else}
<div class="note">
<span class="note-tag">?</span>
<p>{$t('redeployTriggers.form.unknownNote')}</p>
</div>
{/if}
</fieldset>
<!-- Step 04 · Webhook ingress. -->
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num" aria-hidden="true">04</span>
<span class="lbl">{$t('redeployTriggers.detail.webhook')}</span>
<span class="opt">OPTIONAL</span>
</legend>
<div class="row-toggle">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookEnabled')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookEnabledHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookEnabled}
label={$t('redeployTriggers.form.webhookEnabled')}
/>
</div>
{#if webhookEnabled}
<div class="row-toggle indent">
<div class="toggle-copy">
<span class="lbl small">{$t('redeployTriggers.form.webhookRequireSig')}</span>
<p class="hint">{$t('redeployTriggers.form.webhookRequireSigHint')}</p>
</div>
<ToggleSwitch
bind:checked={webhookRequireSig}
label={$t('redeployTriggers.form.webhookRequireSig')}
/>
</div>
{/if}
</fieldset>
<div class="actions">
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={!canSubmit()}
aria-busy={submitting}
>
{submitting
? $t('redeployTriggers.form.submitting')
: $t('redeployTriggers.form.submit')}
</button>
</div>
</form>
</div>
<style>
.forge {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 760px;
margin: 0 auto;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
@media (max-width: 600px) {
.form {
padding: 1.1rem;
gap: 1.25rem;
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: var(--surface-card);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
}
/* ── Field structure ────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
padding: 0;
border: 0;
}
.field.group { gap: 0.75rem; }
.field-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0;
}
.field-label.as-legend { float: none; width: 100%; }
.num {
display: inline-flex;
width: 26px; height: 26px;
justify-content: center; align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem; font-weight: 700;
flex: 0 0 auto;
}
.lbl {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--text-primary);
}
.lbl.small { font-size: 0.95rem; }
.req, .opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req { color: var(--color-danger); }
.opt { color: var(--text-tertiary); }
/* Advanced JSON pill-toggle lives in the same legend row as
the section number. Visually it's a quiet outlined button
that fills in when active. */
.adv-toggle {
margin-left: auto;
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
}
.adv-toggle:hover {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.adv-toggle.on {
background: var(--forge-accent);
border-color: var(--forge-accent);
color: var(--surface-card);
}
/* ── Inputs ─────────────────────────────────────── */
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.6rem 0.8rem;
font-size: 0.92rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.input.mono { font-family: var(--forge-mono); font-size: 0.85rem; }
.input.code {
resize: vertical;
min-height: 140px;
line-height: 1.5;
}
.input.bad { border-color: var(--color-danger); }
.input.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ── Hints ──────────────────────────────────────── */
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
line-height: 1.5;
margin: 0;
}
.hint.danger { color: var(--color-danger); }
.hint em {
font-style: italic;
color: var(--forge-accent);
}
/* ── Note banner (manual/unknown) ─────────────────── */
.note {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.note-tag {
padding: 0.18rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
flex: 0 0 auto;
line-height: 1.4;
}
.note p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* ── Kind picker grid ─────────────────────────────
Each card has a monospace tag and a soft name. The
active card lights up the tag in brand colour and
adds a subtle inner glow so the choice is obvious. */
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
@media (max-width: 600px) {
.kind-grid { grid-template-columns: 1fr; }
}
.kind-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 0.9rem;
text-align: left;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease,
box-shadow 150ms ease;
position: relative;
overflow: hidden;
}
.kind-card:hover {
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-accent);
background: var(--forge-accent-soft);
box-shadow: inset 0 0 0 1px var(--forge-accent);
}
.kind-card-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
}
.kind-card.active .kind-card-tag {
background: var(--forge-accent);
}
.kind-card-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.kind-card-hint {
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.5;
}
/* ── Mode chips (git push vs tag) ─────────────── */
.mode-row {
display: inline-flex;
gap: 0;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
padding: 2px;
width: fit-content;
}
.mode-chip {
padding: 0.32rem 0.85rem;
background: transparent;
border: 0;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.mode-chip:hover { color: var(--text-primary); }
.mode-chip.active {
background: var(--text-primary);
color: var(--surface-card);
}
/* ── Toggle row ─────────────────────────────────── */
.row-toggle {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border-primary);
}
.row-toggle.indent {
border-top: 0;
padding-top: 0.1rem;
padding-left: 1rem;
border-left: 2px solid var(--forge-accent-soft);
margin-left: 0.4rem;
}
.toggle-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Actions ────────────────────────────────────── */
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions :global(.forge-btn),
.actions :global(.forge-btn-ghost) {
justify-content: center;
}
}
</style>