feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s
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:
+121
-69
@@ -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
|
||||
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
|
||||
|
||||
| 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) |
|
||||
| Generalized volume scopes | 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 |
|
||||
| Docs / codemap entries for `internal/workload/plugin/` | 3 | **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
|
||||
Scanner backend + drop-counter stats panel) live in
|
||||
@@ -29,6 +44,110 @@ Scanner backend + drop-counter stats panel) live in
|
||||
|
||||
## 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
|
||||
|
||||
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.)
|
||||
- **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
|
||||
|
||||
### Stages chain vs explicit Stage entity
|
||||
|
||||
@@ -430,6 +430,12 @@ func (s *Server) Router() chi.Router {
|
||||
// running image tag onto this workload's default_tag.
|
||||
r.Get("/chain", s.getWorkloadChain)
|
||||
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.
|
||||
@@ -446,6 +452,26 @@ func (s *Server) Router() chi.Router {
|
||||
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
|
||||
// stream. Read endpoints are available to any authenticated
|
||||
// user; mutations + test-dispatch are admin-gated since they
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -134,10 +134,16 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcilePluginWorkloads iterates every workload row that opted into
|
||||
// the plugin pipeline (source_kind + trigger_kind both set) and asks the
|
||||
// dispatcher to invoke Source.Reconcile. Failures are logged per-workload
|
||||
// — one workload's broken state must not stop sweeping the rest.
|
||||
// reconcilePluginWorkloads iterates every workload row that has a
|
||||
// Source plugin and asks the dispatcher to invoke Source.Reconcile.
|
||||
// Failures are logged per-workload — one workload's broken state must
|
||||
// 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,
|
||||
// disabled deployments, tests).
|
||||
@@ -151,7 +157,7 @@ func (r *Reconciler) reconcilePluginWorkloads(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
for _, w := range rows {
|
||||
if w.SourceKind == "" || w.TriggerKind == "" {
|
||||
if w.SourceKind == "" {
|
||||
continue
|
||||
}
|
||||
pw := toPluginWorkload(w)
|
||||
|
||||
@@ -510,6 +510,43 @@ type Container struct {
|
||||
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).
|
||||
// Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1.
|
||||
type App struct {
|
||||
|
||||
@@ -4,15 +4,41 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a requested entity does not exist.
|
||||
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.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
@@ -274,6 +300,34 @@ func (s *Store) runMigrations() error {
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
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 {
|
||||
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_workload_env_workload ON workload_env(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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -13,7 +13,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -297,11 +296,16 @@ func (h *Handler) Drain() {
|
||||
//
|
||||
// POST /{secret} — per-project deploy 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 {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/sites/{secret}", h.handleSiteWebhook)
|
||||
r.Post("/workloads/{secret}", h.handlePluginWorkloadWebhook)
|
||||
r.Post("/triggers/{secret}", h.handleTriggerWebhook)
|
||||
r.Post("/{secret}", h.handleWebhook)
|
||||
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
|
||||
// plugin.InboundEvent shape. The dispatch order is:
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1186,6 +1186,128 @@ export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise<H
|
||||
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 {
|
||||
id: 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>
|
||||
@@ -17,6 +17,7 @@
|
||||
"apps": "Apps",
|
||||
"eventTriggers": "Triggers",
|
||||
"logScanRules": "Log Rules",
|
||||
"triggers": "Triggers",
|
||||
"projects": "Projects",
|
||||
"deploy": "Deploy",
|
||||
"proxies": "Proxies",
|
||||
@@ -1527,5 +1528,235 @@
|
||||
"overriding": "Overriding…",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"apps": "Приложения",
|
||||
"eventTriggers": "Триггеры",
|
||||
"logScanRules": "Лог-правила",
|
||||
"triggers": "Триггеры",
|
||||
"projects": "Проекты",
|
||||
"deploy": "Деплой",
|
||||
"proxies": "Прокси",
|
||||
@@ -1527,5 +1528,235 @@
|
||||
"overriding": "Переопределение…",
|
||||
"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": "Закрыть"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||
{ 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: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||
];
|
||||
|
||||
+1318
-101
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,43 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { HookKinds, PluginWorkloadInput } from '$lib/types';
|
||||
import type { RedeployTrigger } from '$lib/api';
|
||||
import * as api from '$lib/api';
|
||||
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 name = $state('');
|
||||
let sourceKind = $state('image');
|
||||
let triggerKind = $state('manual');
|
||||
let sourceConfig = $state('{}');
|
||||
let triggerConfig = $state('{}');
|
||||
let publicSubdomain = $state('');
|
||||
let publicDomain = $state('');
|
||||
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
|
||||
// hand-escape YAML inside a JSON string, which is unusable. When the
|
||||
// source plugin is "compose" we surface a dedicated YAML textarea and
|
||||
@@ -79,7 +103,7 @@
|
||||
schemaCache.set(kind, text);
|
||||
return text;
|
||||
} catch {
|
||||
return sourceConfigSample(kind) || triggerConfigSample(kind) || '{}';
|
||||
return sourceConfigSample(kind) || '{}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,11 +298,7 @@
|
||||
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
|
||||
sourceKind = kinds.sources[0];
|
||||
}
|
||||
if (kinds.triggers.length > 0 && !kinds.triggers.includes(triggerKind)) {
|
||||
triggerKind = kinds.triggers[0];
|
||||
}
|
||||
sourceConfig = await fetchSampleJSON(sourceKind);
|
||||
triggerConfig = await fetchSampleJSON(triggerKind);
|
||||
if (sourceKind === 'compose') {
|
||||
seedComposeFromJSON(sourceConfig);
|
||||
}
|
||||
@@ -296,6 +316,14 @@
|
||||
} catch {
|
||||
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) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
|
||||
} 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() {
|
||||
sourceConfig = await fetchSampleJSON(sourceKind);
|
||||
// Switching INTO compose / image seeds the form fields from
|
||||
@@ -389,9 +397,6 @@
|
||||
advancedJson = false;
|
||||
}
|
||||
}
|
||||
async function onTriggerChange() {
|
||||
triggerConfig = await fetchSampleJSON(triggerKind);
|
||||
}
|
||||
|
||||
// Toggle between the kind-aware form and the raw JSON editor.
|
||||
// Direction matters: going to Advanced JSON commits the form fields
|
||||
@@ -423,12 +428,17 @@
|
||||
}
|
||||
}
|
||||
const sourceValid = $derived(jsonOk(sourceConfig));
|
||||
const triggerValid = $derived(jsonOk(triggerConfig));
|
||||
|
||||
const sourceLines = $derived(sourceConfig.split('\n').length);
|
||||
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) {
|
||||
e.preventDefault();
|
||||
@@ -436,7 +446,6 @@
|
||||
submitting = true;
|
||||
try {
|
||||
let parsedSource: unknown;
|
||||
let parsedTrigger: unknown;
|
||||
if (useComposeForm) {
|
||||
// Form fields are typed primitives — no parse step
|
||||
// needed. compose_yaml passes through verbatim; the
|
||||
@@ -459,18 +468,17 @@
|
||||
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 = {
|
||||
name: name.trim(),
|
||||
source_kind: sourceKind,
|
||||
source_config: parsedSource,
|
||||
trigger_kind: triggerKind,
|
||||
trigger_config: parsedTrigger
|
||||
trigger_kind: '',
|
||||
trigger_config: {}
|
||||
};
|
||||
if (publicSubdomain || publicDomain || publicPort > 0) {
|
||||
body.public_faces = [
|
||||
@@ -486,6 +494,35 @@
|
||||
}
|
||||
|
||||
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}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Create failed';
|
||||
@@ -502,8 +539,7 @@
|
||||
<div class="forge">
|
||||
{#snippet newLede()}
|
||||
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
|
||||
independently extensible.
|
||||
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
@@ -553,39 +589,25 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Plugins</span>
|
||||
<span class="opt">SOURCE × TRIGGER</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>
|
||||
<span class="lbl">Source plugin</span>
|
||||
<span class="opt">REQUIRED</span>
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -921,10 +943,10 @@
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label class="toggle-row">
|
||||
<ToggleSwitch
|
||||
bind:checked={staticRenderMarkdown}
|
||||
label="Render markdown"
|
||||
/>
|
||||
<span>
|
||||
<strong>Render markdown</strong> — auto-render <code>.md</code>
|
||||
@@ -982,45 +1004,108 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<fieldset class="field group">
|
||||
<legend class="field-label as-legend">
|
||||
<span class="num">04</span>
|
||||
<span class="lbl">Trigger config</span>
|
||||
<span class="req">JSON</span>
|
||||
<span class="lbl">{$t('apps.new.triggers.section')}</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 class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">trigger_config.json · {triggerKind}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={() => (triggerConfig = triggerConfigSample(triggerKind))}
|
||||
>
|
||||
Reset sample
|
||||
</button>
|
||||
|
||||
{#if triggerMode === 'inline'}
|
||||
<div class="trig-sub">
|
||||
<TriggerKindForm
|
||||
bind:state={triggerForm}
|
||||
idPrefix="app-trig"
|
||||
showName={true}
|
||||
showWebhook={true}
|
||||
showKindPicker={true}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
id="app-trigger-config"
|
||||
bind:value={triggerConfig}
|
||||
rows="7"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Trigger plugin configuration (JSON)"
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status" class:bad={!triggerValid}>
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
{triggerValid ? 'JSON OK' : 'JSON INVALID'}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{triggerLines} lines</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{triggerBytes} B</span>
|
||||
{:else if triggerMode === 'pick'}
|
||||
<div class="trig-sub">
|
||||
{#if existingTriggers.length === 0}
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">∅</span>
|
||||
<p>{$t('apps.new.triggers.pickEmpty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="sub" for="app-trig-pick">
|
||||
<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
|
||||
<select
|
||||
id="app-trig-pick"
|
||||
class="input"
|
||||
bind:value={pickedTriggerId}
|
||||
>
|
||||
<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
|
||||
{#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 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">
|
||||
<legend class="field-label as-legend">
|
||||
@@ -1074,7 +1159,7 @@
|
||||
<button
|
||||
class="btn-primary"
|
||||
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 class="arrow" aria-hidden="true">→</span>
|
||||
@@ -1553,16 +1638,15 @@
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio input,
|
||||
.checkbox-row input {
|
||||
.radio input {
|
||||
margin-top: 0.18rem;
|
||||
accent-color: var(--color-brand-500);
|
||||
}
|
||||
.radio strong,
|
||||
.checkbox-row strong {
|
||||
.toggle-row strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.checkbox-row {
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
@@ -1571,4 +1655,108 @@
|
||||
color: var(--text-secondary);
|
||||
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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user