refactor(triggers): review followups — fire-now, dedupe trigger pages, hardening
Build / build (push) Failing after 34s
Build / build (push) Failing after 34s
Follow-ups on commit 39e1e36 addressing review feedback from
go-reviewer / security-reviewer / typescript-reviewer.
Backend:
- New POST /api/triggers/{id}/fire (AdminOnly, schedule-only): operator
"Fire now" button — dispatches immediately without waiting for the
next natural interval. Persists last_fired_at BEFORE dispatch, same
ordering as the scheduler. Per-trigger in-flight guard (429 if a
fire is already running) to defend against rapid double-clicks /
runaway scripts. Refuses request when AdminOnly claims are absent
rather than logging an unattributable deploy.
- SetTriggerLastFired now validates timestamp parses as RFC3339 before
writing. Rejects empty string explicitly — empty-clears semantics
were dead (no caller) and would silently re-fire on next tick if
ever accidentally written. A future reset-cadence flow must add a
dedicated ClearTriggerLastFired so the call site is grep-able and
separately auditable.
- Scheduler logs WARN on catch-up fires (now - lastFired > 2× interval)
so the "surprise burst at restart" pattern shows up in audit logs.
- BindingResult reason strings extracted to package consts
(webhook.Reason*) so the scheduler and api fire-now classifications
stay in sync without string-matching drift.
- SECURITY NOTE on FanOutForTrigger documents that the
WebhookRequireSignature gate is ingress-only by design.
Frontend:
- Refactored /triggers/new (770 LOC → 155 LOC) and /triggers/[id]
(~350 LOC dropped) to use the shared TriggerKindForm. Eliminates the
triplicated per-kind state + buildConfig + canSubmit + template that
caused the d-unit regex drift in the prior commit.
- New seedTriggerKindFormState helper on TriggerKindForm primes the
form from a server-returned trigger config with defensive type
guards; resets per-kind slots first so re-seeding across kinds
doesn't inherit stale state.
- /triggers/[id] gains a Schedule status panel with Last Fired + Fire
Now button (gated on binding_count > 0). Confirmation dialog,
result flash, timer cleanup on unmount + new-fire (no stale-closure
race). EN+RU i18n parity.
This commit is contained in:
+1
-1
@@ -197,7 +197,7 @@ func main() {
|
|||||||
switch {
|
switch {
|
||||||
case r.Deployed:
|
case r.Deployed:
|
||||||
deployed++
|
deployed++
|
||||||
case r.Reason == "binding disabled", r.Reason == "no match":
|
case r.Reason == webhook.ReasonBindingDisabled, r.Reason == webhook.ReasonNoMatch:
|
||||||
// not a failure — silent
|
// not a failure — silent
|
||||||
default:
|
default:
|
||||||
errored++
|
errored++
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Delete("/triggers/{id}", s.deleteTrigger)
|
r.Delete("/triggers/{id}", s.deleteTrigger)
|
||||||
r.Get("/triggers/{id}/webhook", s.getTriggerWebhook)
|
r.Get("/triggers/{id}/webhook", s.getTriggerWebhook)
|
||||||
r.Post("/triggers/{id}/webhook/regenerate", s.regenerateTriggerWebhook)
|
r.Post("/triggers/{id}/webhook/regenerate", s.regenerateTriggerWebhook)
|
||||||
|
r.Post("/triggers/{id}/fire", s.fireTriggerNow)
|
||||||
r.Post("/triggers/{id}/bindings", s.bindWorkloadToTrigger)
|
r.Post("/triggers/{id}/bindings", s.bindWorkloadToTrigger)
|
||||||
r.Put("/bindings/{bid}", s.updateBinding)
|
r.Put("/bindings/{bid}", s.updateBinding)
|
||||||
r.Delete("/bindings/{bid}", s.deleteBinding)
|
r.Delete("/bindings/{bid}", s.deleteBinding)
|
||||||
|
|||||||
@@ -7,13 +7,27 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/alexei/tinyforge/internal/auth"
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
|
"github.com/alexei/tinyforge/internal/webhook"
|
||||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// fireInFlight tracks trigger IDs that have a fire-now request actively
|
||||||
|
// running so a runaway script or rapid double-click doesn't queue
|
||||||
|
// duplicate deploys. Keyed by trigger ID; entries are added under the
|
||||||
|
// mutex and removed by the handler's defer. Sufficient for an admin
|
||||||
|
// gate — a real rate limiter belongs at the middleware layer, not here.
|
||||||
|
var (
|
||||||
|
fireInFlightMu sync.Mutex
|
||||||
|
fireInFlight = map[string]struct{}{}
|
||||||
|
)
|
||||||
|
|
||||||
// triggerView is the response shape for /api/triggers. Webhook secrets
|
// triggerView is the response shape for /api/triggers. Webhook secrets
|
||||||
// are never serialized — read them via the dedicated /webhook subresource
|
// are never serialized — read them via the dedicated /webhook subresource
|
||||||
// where the canonical URL is composed.
|
// where the canonical URL is composed.
|
||||||
@@ -251,6 +265,126 @@ func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, view)
|
respondJSON(w, http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fireTriggerNow dispatches a trigger immediately without waiting for
|
||||||
|
// its next natural fire window. Used by the /triggers/[id] "Fire now"
|
||||||
|
// button so an operator can re-test a fixed broken deploy without
|
||||||
|
// waiting one full schedule interval.
|
||||||
|
//
|
||||||
|
// Scope: schedule triggers only. Other kinds (registry / git / manual)
|
||||||
|
// already have their own dispatch paths — registry/git fire on real
|
||||||
|
// inbound events, manual fires from the workload Deploy button. Adding
|
||||||
|
// "fire-now" for those would duplicate those flows without adding new
|
||||||
|
// capability.
|
||||||
|
//
|
||||||
|
// Side effect: updates last_fired_at to "now" (same persist-before-
|
||||||
|
// dispatch ordering the scheduler uses) so the natural next-fire
|
||||||
|
// window shifts forward by exactly the interval. This is the
|
||||||
|
// principle-of-least-surprise behavior — an operator who fires now
|
||||||
|
// is intentionally resetting the cadence.
|
||||||
|
func (s *Server) fireTriggerNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Per-trigger in-flight guard. AdminOnly + UI throttle is the only
|
||||||
|
// gate against rapid double-clicks; without this guard a runaway
|
||||||
|
// script could queue parallel fans-out of the same schedule, each
|
||||||
|
// holding up to maxTriggerFanOutConcurrency deployer slots.
|
||||||
|
// Returning 429 lets the client distinguish "already running" from
|
||||||
|
// a real validation error.
|
||||||
|
fireInFlightMu.Lock()
|
||||||
|
if _, busy := fireInFlight[id]; busy {
|
||||||
|
fireInFlightMu.Unlock()
|
||||||
|
respondError(w, http.StatusTooManyRequests,
|
||||||
|
"a fire is already in progress for this trigger")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fireInFlight[id] = struct{}{}
|
||||||
|
fireInFlightMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
fireInFlightMu.Lock()
|
||||||
|
delete(fireInFlight, id)
|
||||||
|
fireInFlightMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
trg, err := s.store.GetTriggerByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
respondNotFound(w, "trigger")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load trigger")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if trg.Kind != "schedule" {
|
||||||
|
respondError(w, http.StatusBadRequest,
|
||||||
|
"fire-now is only supported for schedule triggers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOnly middleware guarantees claims; treat their absence as a
|
||||||
|
// boot-time wiring bug rather than fall back to an unattributable
|
||||||
|
// "manual" string that collides with the `manual` trigger kind in
|
||||||
|
// audit logs.
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok || claims.Username == "" {
|
||||||
|
slog.Error("fire-now: missing claims under AdminOnly", "trigger", trg.Name)
|
||||||
|
respondError(w, http.StatusInternalServerError, "missing auth context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := claims.Username
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := s.store.SetTriggerLastFired(trg.ID, now.Format(time.RFC3339)); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "persist last_fired_at")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := plugin.InboundEvent{
|
||||||
|
Kind: "schedule",
|
||||||
|
Schedule: &plugin.ScheduleEvent{FiredAt: now},
|
||||||
|
}
|
||||||
|
results, err := s.webhook.FanOutForTrigger(r.Context(), trg, evt)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("fire-now: fan-out failed",
|
||||||
|
"trigger", trg.Name, "actor", actor, "error", err)
|
||||||
|
// Don't expose the raw error — it can carry registry-auth or
|
||||||
|
// compose-stdout bytes (matches the manual-deploy handler).
|
||||||
|
respondError(w, http.StatusInternalServerError, "fire failed; see server logs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var deployed, errored int
|
||||||
|
for _, b := range results {
|
||||||
|
switch {
|
||||||
|
case b.Deployed:
|
||||||
|
deployed++
|
||||||
|
case b.Reason == webhook.ReasonBindingDisabled, b.Reason == webhook.ReasonNoMatch:
|
||||||
|
// silent
|
||||||
|
default:
|
||||||
|
errored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Empty fan-out (no bindings) is almost certainly an operator
|
||||||
|
// mistake — the UI button is gated on binding_count>0, but the
|
||||||
|
// counts can change between page load and click. Warn so the
|
||||||
|
// no-op shows up in audit logs.
|
||||||
|
if len(results) == 0 {
|
||||||
|
slog.Warn("fire-now: no bindings to fire",
|
||||||
|
"trigger", trg.Name, "actor", actor)
|
||||||
|
} else {
|
||||||
|
slog.Info("fire-now dispatched",
|
||||||
|
"trigger", trg.Name, "actor", actor,
|
||||||
|
"bindings", len(results), "deployed", deployed, "errored", errored)
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"trigger": trg.Name,
|
||||||
|
"fired_at": now.Format(time.RFC3339),
|
||||||
|
"bindings": len(results),
|
||||||
|
"deployed": deployed,
|
||||||
|
"errored": errored,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
secret := generateWebhookSecret()
|
secret := generateWebhookSecret()
|
||||||
|
|||||||
@@ -171,9 +171,31 @@ func (s *Scheduler) shouldFire(t store.Trigger, now time.Time) bool {
|
|||||||
// require a manual DB poke.
|
// require a manual DB poke.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return !now.Before(last.Add(interval))
|
if now.Before(last.Add(interval)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Catch-up warning: a trigger whose last_fired_at is many intervals
|
||||||
|
// old (paused-then-resumed, restored from backup, or just left
|
||||||
|
// running while the dispatcher was down) WILL fire on this tick.
|
||||||
|
// Log a one-line warning so the operator can recognize the "surprise
|
||||||
|
// burst at restart" pattern in audit logs. We still fire — silent
|
||||||
|
// no-fire would be worse — but the warning explains why.
|
||||||
|
if overdue := now.Sub(last); overdue > catchUpWarnThreshold*interval {
|
||||||
|
slog.Warn("scheduler: catch-up fire (very overdue)",
|
||||||
|
"trigger", t.Name, "overdue", overdue, "interval", interval)
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catchUpWarnThreshold is the multiplier on `interval` past which a
|
||||||
|
// fire is logged as "catch-up." 2× means a daily schedule whose last
|
||||||
|
// fire was more than 48h ago gets a warning at next tick. Chosen so
|
||||||
|
// the warning fires on "wedged for many intervals" without alerting on
|
||||||
|
// the every-tick lag a healthy 30s-tick scheduler accumulates against
|
||||||
|
// a sub-minute interval. Bigger threshold = noisier-quiet trade-off;
|
||||||
|
// 2× is the smallest value that excludes single-tick lag.
|
||||||
|
const catchUpWarnThreshold = 2
|
||||||
|
|
||||||
// fire dispatches one trigger and records the new last_fired_at.
|
// fire dispatches one trigger and records the new last_fired_at.
|
||||||
//
|
//
|
||||||
// We persist last_fired_at BEFORE calling the dispatcher so a panic
|
// We persist last_fired_at BEFORE calling the dispatcher so a panic
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -307,7 +308,17 @@ func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
|
|||||||
// so the value is stable across timezones. Updating last_fired_at does
|
// so the value is stable across timezones. Updating last_fired_at does
|
||||||
// not bump updated_at — last_fired_at is operational state, while
|
// not bump updated_at — last_fired_at is operational state, while
|
||||||
// updated_at tracks user-visible config edits.
|
// updated_at tracks user-visible config edits.
|
||||||
|
//
|
||||||
|
// ts must parse as RFC3339 — a defense-in-depth check so a careless
|
||||||
|
// caller cannot corrupt the column with a garbage string the scheduler
|
||||||
|
// would refuse to parse on every tick. To clear the column (effectively
|
||||||
|
// "fire on next tick"), use a separate API rather than passing empty
|
||||||
|
// here; the narrow contract keeps the call site grep-able and forces
|
||||||
|
// any reset-cadence flow to be explicitly designed and authorized.
|
||||||
func (s *Store) SetTriggerLastFired(id, ts string) error {
|
func (s *Store) SetTriggerLastFired(id, ts string) error {
|
||||||
|
if _, err := time.Parse(time.RFC3339, ts); err != nil {
|
||||||
|
return fmt.Errorf("invalid last_fired_at %q (want RFC3339): %w", ts, err)
|
||||||
|
}
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE triggers SET last_fired_at = ? WHERE id = ?`,
|
`UPDATE triggers SET last_fired_at = ? WHERE id = ?`,
|
||||||
ts, id,
|
ts, id,
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ type BindingResult struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reason strings used in BindingResult.Reason. Exported so callers
|
||||||
|
// classifying fan-out outcomes (e.g. the API fire-now summary log)
|
||||||
|
// don't need to keep string literals in sync with this package.
|
||||||
|
const (
|
||||||
|
ReasonBindingDisabled = "binding disabled"
|
||||||
|
ReasonWorkloadMissing = "workload missing"
|
||||||
|
ReasonNoMatch = "no match"
|
||||||
|
ReasonConfigError = "config merge error"
|
||||||
|
ReasonMatchError = "match error"
|
||||||
|
ReasonDispatchFailed = "dispatch failed"
|
||||||
|
)
|
||||||
|
|
||||||
// handleTriggerWebhook processes an inbound webhook for a first-class
|
// handleTriggerWebhook processes an inbound webhook for a first-class
|
||||||
// Trigger record. The secret resolves to one Trigger; the Trigger then
|
// Trigger record. The secret resolves to one Trigger; the Trigger then
|
||||||
// fans out to every enabled workload binding. Each binding gets its
|
// fans out to every enabled workload binding. Each binding gets its
|
||||||
@@ -160,9 +172,9 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
switch {
|
switch {
|
||||||
case r.Deployed:
|
case r.Deployed:
|
||||||
deployed++
|
deployed++
|
||||||
case r.Reason == "binding disabled":
|
case r.Reason == ReasonBindingDisabled:
|
||||||
skipped++
|
skipped++
|
||||||
case r.Reason == "no match":
|
case r.Reason == ReasonNoMatch:
|
||||||
noMatch++
|
noMatch++
|
||||||
default:
|
default:
|
||||||
errored++
|
errored++
|
||||||
@@ -198,6 +210,14 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
// triggers without a real HTTP request — same dispatch path, same
|
// triggers without a real HTTP request — same dispatch path, same
|
||||||
// per-binding isolation, same outcome shape.
|
// per-binding isolation, same outcome shape.
|
||||||
//
|
//
|
||||||
|
// SECURITY NOTE: trg.WebhookSigningSecret + WebhookRequireSignature
|
||||||
|
// gate INBOUND HTTP only (handleTriggerWebhook). This method skips
|
||||||
|
// that check by design because the caller is first-party in-process
|
||||||
|
// code — no untrusted bytes flow in here. If you add a new caller
|
||||||
|
// outside the scheduler / inbound webhook, audit the call site for
|
||||||
|
// authorization first; this is not a generic "fire any trigger"
|
||||||
|
// entry point.
|
||||||
|
//
|
||||||
// Returns nil + error only when the trigger plugin is missing or the
|
// Returns nil + error only when the trigger plugin is missing or the
|
||||||
// bindings query fails — both fatal upstream conditions the caller
|
// bindings query fails — both fatal upstream conditions the caller
|
||||||
// should log. A per-binding error becomes a row in the result slice
|
// should log. A per-binding error becomes a row in the result slice
|
||||||
@@ -248,14 +268,14 @@ func (h *Handler) fanOutBindings(
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, b := range bindings {
|
for i, b := range bindings {
|
||||||
if !b.Enabled {
|
if !b.Enabled {
|
||||||
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "binding disabled"}
|
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: ReasonBindingDisabled}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
row, lookupErr := h.store.GetWorkloadByID(b.WorkloadID)
|
row, lookupErr := h.store.GetWorkloadByID(b.WorkloadID)
|
||||||
if lookupErr != nil {
|
if lookupErr != nil {
|
||||||
slog.Warn("webhook: bound workload missing",
|
slog.Warn("webhook: bound workload missing",
|
||||||
"trigger", trg.Name, "workload", b.WorkloadID, "error", lookupErr)
|
"trigger", trg.Name, "workload", b.WorkloadID, "error", lookupErr)
|
||||||
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: "workload missing"}
|
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: ReasonWorkloadMissing}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -289,16 +309,16 @@ func (h *Handler) fireBinding(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("webhook: merge effective trigger config failed",
|
slog.Warn("webhook: merge effective trigger config failed",
|
||||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||||
return false, "config merge error"
|
return false, ReasonConfigError
|
||||||
}
|
}
|
||||||
intent, err := trigPlugin.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
|
intent, err := trigPlugin.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("webhook: trigger match error",
|
slog.Warn("webhook: trigger match error",
|
||||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||||
return false, "match error"
|
return false, ReasonMatchError
|
||||||
}
|
}
|
||||||
if intent == nil {
|
if intent == nil {
|
||||||
return false, "no match"
|
return false, ReasonNoMatch
|
||||||
}
|
}
|
||||||
if intent.TriggeredAt.IsZero() {
|
if intent.TriggeredAt.IsZero() {
|
||||||
intent.TriggeredAt = time.Now().UTC()
|
intent.TriggeredAt = time.Now().UTC()
|
||||||
@@ -309,7 +329,7 @@ func (h *Handler) fireBinding(
|
|||||||
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
|
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
|
||||||
slog.Warn("webhook: dispatch failed",
|
slog.Warn("webhook: dispatch failed",
|
||||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||||
return false, "dispatch failed"
|
return false, ReasonDispatchFailed
|
||||||
}
|
}
|
||||||
slog.Info("webhook: triggered deploy via trigger fan-out",
|
slog.Info("webhook: triggered deploy via trigger fan-out",
|
||||||
"trigger", trg.Name, "workload", row.Name, "reason", intent.Reason)
|
"trigger", trg.Name, "workload", row.Name, "reason", intent.Reason)
|
||||||
|
|||||||
@@ -801,6 +801,20 @@ export function regenerateTriggerWebhook(id: string): Promise<{ secret: string;
|
|||||||
return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`);
|
return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FireNowResponse {
|
||||||
|
trigger: string;
|
||||||
|
fired_at: string;
|
||||||
|
bindings: number;
|
||||||
|
deployed: number;
|
||||||
|
errored: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire a schedule trigger immediately without waiting for the next
|
||||||
|
* natural fire window. Backend rejects with 400 for non-schedule kinds. */
|
||||||
|
export function fireTriggerNow(id: string): Promise<FireNowResponse> {
|
||||||
|
return post<FireNowResponse>(`/api/triggers/${id}/fire`);
|
||||||
|
}
|
||||||
|
|
||||||
export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise<TriggerBinding[]> {
|
export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise<TriggerBinding[]> {
|
||||||
return get<TriggerBinding[]>(`/api/triggers/${id}/bindings`, signal);
|
return get<TriggerBinding[]>(`/api/triggers/${id}/bindings`, signal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset every per-kind slot to its default. Called by
|
||||||
|
* seedTriggerKindFormState before re-seeding so a caller that
|
||||||
|
* re-seeds across kinds (draft restore, future flows) does not
|
||||||
|
* inherit stale state from the previous kind's slots. The factory
|
||||||
|
* defaults live in createTriggerKindFormState — we restate them
|
||||||
|
* here rather than re-instantiating because the parent binds
|
||||||
|
* the state object by reference. */
|
||||||
|
function resetKindSlots(s: TriggerKindFormState): void {
|
||||||
|
s.regImage = '';
|
||||||
|
s.regTagPattern = '*';
|
||||||
|
s.gitRepo = '';
|
||||||
|
s.gitMode = 'push';
|
||||||
|
s.gitBranch = 'main';
|
||||||
|
s.gitTagPattern = 'v*';
|
||||||
|
s.schInterval = '24h';
|
||||||
|
s.schReference = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed an existing form state in place from a server-returned
|
||||||
|
* trigger config blob. Used by the /triggers/[id] edit page so the
|
||||||
|
* same component renders identically on create + edit. Unknown
|
||||||
|
* kinds force the advanced-JSON fallback. Typed defensively — a
|
||||||
|
* malformed config value falls back to the default rather than
|
||||||
|
* stringifying garbage into an input box. Safe to call repeatedly
|
||||||
|
* across kinds: every per-kind slot is reset before the switch. */
|
||||||
|
export function seedTriggerKindFormState(
|
||||||
|
s: TriggerKindFormState,
|
||||||
|
kind: string,
|
||||||
|
name: string,
|
||||||
|
config: unknown,
|
||||||
|
webhookEnabled: boolean,
|
||||||
|
webhookRequireSig: boolean
|
||||||
|
): void {
|
||||||
|
resetKindSlots(s);
|
||||||
|
s.kind = kind;
|
||||||
|
s.name = name;
|
||||||
|
s.webhookEnabled = webhookEnabled;
|
||||||
|
s.webhookRequireSig = webhookRequireSig;
|
||||||
|
const cfg = (config ?? {}) as Record<string, unknown>;
|
||||||
|
// Prime the JSON text so toggling Advanced reveals the canonical
|
||||||
|
// shape rather than a blank box. JSON.stringify of a plain
|
||||||
|
// object only throws on cyclic refs, which a JSON-deserialized
|
||||||
|
// response cannot contain — no try/catch needed.
|
||||||
|
s.jsonText = JSON.stringify(cfg, null, 2);
|
||||||
|
// Force JSON-only mode for unknown kinds — the structured form
|
||||||
|
// has no branch for them.
|
||||||
|
const isKnown = (KNOWN_KINDS as readonly string[]).includes(kind);
|
||||||
|
if (!isKnown) {
|
||||||
|
s.useAdvancedJson = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.useAdvancedJson = false;
|
||||||
|
switch (kind) {
|
||||||
|
case 'registry':
|
||||||
|
s.regImage = typeof cfg.image === 'string' ? cfg.image : '';
|
||||||
|
s.regTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : '*';
|
||||||
|
break;
|
||||||
|
case 'git':
|
||||||
|
s.gitRepo = typeof cfg.repo === 'string' ? cfg.repo : '';
|
||||||
|
// Backend Validate enforces mode ∈ {push, tag}. Anything
|
||||||
|
// else (undefined, "PUSH" case mismatch) collapses to
|
||||||
|
// "push" — the safe default, but worth flagging so an
|
||||||
|
// operator who hand-edited an invalid mode in the DB
|
||||||
|
// understands the silent rewrite.
|
||||||
|
s.gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
|
||||||
|
s.gitBranch = typeof cfg.branch === 'string' ? cfg.branch : 'main';
|
||||||
|
s.gitTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : 'v*';
|
||||||
|
break;
|
||||||
|
case 'manual':
|
||||||
|
// no structured fields
|
||||||
|
break;
|
||||||
|
case 'schedule':
|
||||||
|
s.schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h';
|
||||||
|
s.schReference = typeof cfg.reference === 'string' ? cfg.reference : '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTriggerInput(s: TriggerKindFormState): TriggerInput {
|
export function buildTriggerInput(s: TriggerKindFormState): TriggerInput {
|
||||||
let config: unknown;
|
let config: unknown;
|
||||||
if (s.useAdvancedJson) {
|
if (s.useAdvancedJson) {
|
||||||
|
|||||||
@@ -1085,7 +1085,17 @@
|
|||||||
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
|
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
|
||||||
"unbindConfirm": "Unbind",
|
"unbindConfirm": "Unbind",
|
||||||
"lastFired": "Last fired",
|
"lastFired": "Last fired",
|
||||||
"lastFiredNever": "Never fired"
|
"lastFiredNever": "Never fired",
|
||||||
|
"scheduleStatus": "Schedule status",
|
||||||
|
"scheduleStatusSub": "Operational state of the internal scheduler for this trigger. Fire-now skips ahead of the next natural window and resets the cadence to start counting from now.",
|
||||||
|
"fireNow": "Fire now",
|
||||||
|
"fireNowTitle": "Dispatch this trigger immediately and reset the next-fire window.",
|
||||||
|
"fireNowDisabledTitle": "Bind at least one workload before firing.",
|
||||||
|
"firing": "Firing…",
|
||||||
|
"fireConfirmTitle": "Fire schedule trigger?",
|
||||||
|
"fireConfirmMessage": "Trigger \"{name}\" will fire immediately and fan out to its {count} bound workload(s). The next natural fire window will be one full interval from now.",
|
||||||
|
"fireConfirm": "Fire now",
|
||||||
|
"fireResult": "Fired · deployed {deployed}/{bindings} · errored {errored}"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"kindLabel": "Kind",
|
"kindLabel": "Kind",
|
||||||
|
|||||||
@@ -1085,7 +1085,17 @@
|
|||||||
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
|
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
|
||||||
"unbindConfirm": "Отвязать",
|
"unbindConfirm": "Отвязать",
|
||||||
"lastFired": "Последний запуск",
|
"lastFired": "Последний запуск",
|
||||||
"lastFiredNever": "Ни разу не срабатывал"
|
"lastFiredNever": "Ни разу не срабатывал",
|
||||||
|
"scheduleStatus": "Состояние расписания",
|
||||||
|
"scheduleStatusSub": "Рабочее состояние внутреннего планировщика для этого триггера. «Запустить сейчас» сдвигает следующий запуск и начинает отсчёт нового интервала с этого момента.",
|
||||||
|
"fireNow": "Запустить сейчас",
|
||||||
|
"fireNowTitle": "Запустить триггер немедленно и сбросить окно следующего срабатывания.",
|
||||||
|
"fireNowDisabledTitle": "Привяжите хотя бы одну нагрузку перед запуском.",
|
||||||
|
"firing": "Запуск…",
|
||||||
|
"fireConfirmTitle": "Запустить триггер расписания?",
|
||||||
|
"fireConfirmMessage": "Триггер «{name}» сработает немедленно и развернёт {count} связанных нагрузок. Следующий естественный запуск будет через полный интервал от текущего момента.",
|
||||||
|
"fireConfirm": "Запустить",
|
||||||
|
"fireResult": "Сработал · задеплоено {deployed}/{bindings} · ошибок {errored}"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"kindLabel": "Вид",
|
"kindLabel": "Вид",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import TriggerKindForm, {
|
||||||
|
createTriggerKindFormState,
|
||||||
|
seedTriggerKindFormState,
|
||||||
|
isTriggerFormValid,
|
||||||
|
buildTriggerInput
|
||||||
|
} from '$lib/components/TriggerKindForm.svelte';
|
||||||
import { IconCopy, IconRefresh, IconTrash, IconExternalLink } from '$lib/components/icons';
|
import { IconCopy, IconRefresh, IconTrash, IconExternalLink } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
@@ -21,29 +27,6 @@
|
|||||||
// the type checker — server validation rejects empty ids anyway.
|
// the type checker — server validation rejects empty ids anyway.
|
||||||
const id = $derived($page.params.id ?? '');
|
const id = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
|
|
||||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
|
||||||
|
|
||||||
const SCHEDULE_PRESETS = [
|
|
||||||
{ key: 'hourly', value: '1h' },
|
|
||||||
{ key: 'daily', value: '24h' },
|
|
||||||
{ key: 'weekly', value: '168h' }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function isValidInterval(s: string): boolean {
|
|
||||||
const trimmed = s.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
|
||||||
if (single) {
|
|
||||||
const n = parseInt(single[1], 10);
|
|
||||||
const unit = single[2].toLowerCase();
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return false;
|
|
||||||
if (unit === 's' && n < 60) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLastFired(ts: string): string {
|
function formatLastFired(ts: string): string {
|
||||||
if (!ts) return $t('redeployTriggers.detail.lastFiredNever');
|
if (!ts) return $t('redeployTriggers.detail.lastFiredNever');
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
@@ -70,106 +53,12 @@
|
|||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
|
||||||
// Form fields. Mirrors the new-page wizard but seeded from
|
// All kind-aware form state lives in the shared TriggerKindForm
|
||||||
// the loaded trigger's config. The kind field stays read-only
|
// component. seedTriggerKindFormState() primes it from the loaded
|
||||||
// after creation (changing kind would invalidate the config
|
// trigger; buildTriggerInput() reads it back on save. Kind stays
|
||||||
// shape and the server doesn't support it).
|
// read-only after creation — TriggerKindForm renders a static
|
||||||
let name = $state('');
|
// kind tag when showKindPicker=false.
|
||||||
let webhookEnabled = $state(false);
|
let formState = $state(createTriggerKindFormState());
|
||||||
let webhookRequireSig = $state(true);
|
|
||||||
let useAdvancedJson = $state(false);
|
|
||||||
|
|
||||||
// Per-kind structured slots.
|
|
||||||
let regImage = $state('');
|
|
||||||
let regTagPattern = $state('*');
|
|
||||||
let gitRepo = $state('');
|
|
||||||
let gitMode = $state<'push' | 'tag'>('push');
|
|
||||||
let gitBranch = $state('main');
|
|
||||||
let gitTagPattern = $state('v*');
|
|
||||||
let schInterval = $state('24h');
|
|
||||||
let schReference = $state('');
|
|
||||||
|
|
||||||
let jsonText = $state('');
|
|
||||||
|
|
||||||
const jsonValid = $derived.by(() => {
|
|
||||||
if (!useAdvancedJson) return true;
|
|
||||||
if (!jsonText.trim()) return true;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(jsonText);
|
|
||||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function isKnownKind(k: string): k is KnownKind {
|
|
||||||
return (KNOWN_KINDS as readonly string[]).includes(k);
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedFormFromConfig(trig: RedeployTrigger): void {
|
|
||||||
name = trig.name;
|
|
||||||
webhookEnabled = trig.webhook_enabled;
|
|
||||||
webhookRequireSig = trig.webhook_require_signature;
|
|
||||||
const cfg = (trig.config ?? {}) as Record<string, unknown>;
|
|
||||||
// Always prime the JSON text so toggling Advanced never
|
|
||||||
// flashes an empty box.
|
|
||||||
try {
|
|
||||||
jsonText = JSON.stringify(cfg, null, 2);
|
|
||||||
} catch {
|
|
||||||
jsonText = '{}';
|
|
||||||
}
|
|
||||||
// Fall back to advanced JSON for kinds without a structured
|
|
||||||
// form so the operator can still edit unknown kinds safely.
|
|
||||||
if (!isKnownKind(trig.kind)) {
|
|
||||||
useAdvancedJson = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (trig.kind) {
|
|
||||||
case 'registry':
|
|
||||||
regImage = String(cfg.image ?? '');
|
|
||||||
regTagPattern = String(cfg.tag_pattern ?? '*');
|
|
||||||
break;
|
|
||||||
case 'git':
|
|
||||||
gitRepo = String(cfg.repo ?? '');
|
|
||||||
gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
|
|
||||||
gitBranch = String(cfg.branch ?? 'main');
|
|
||||||
gitTagPattern = String(cfg.tag_pattern ?? 'v*');
|
|
||||||
break;
|
|
||||||
case 'manual':
|
|
||||||
// no fields
|
|
||||||
break;
|
|
||||||
case 'schedule':
|
|
||||||
schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h';
|
|
||||||
schReference = typeof cfg.reference === 'string' ? cfg.reference : '';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfig(): unknown {
|
|
||||||
if (!trigger) return {};
|
|
||||||
if (useAdvancedJson) {
|
|
||||||
if (!jsonText.trim()) return {};
|
|
||||||
return JSON.parse(jsonText);
|
|
||||||
}
|
|
||||||
switch (trigger.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 {};
|
|
||||||
case 'schedule': {
|
|
||||||
const ref = schReference.trim();
|
|
||||||
return ref
|
|
||||||
? { interval: schInterval.trim(), reference: ref }
|
|
||||||
: { interval: schInterval.trim() };
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return JSON.parse(jsonText || '{}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -177,7 +66,14 @@
|
|||||||
try {
|
try {
|
||||||
const tr = await api.getTrigger(id);
|
const tr = await api.getTrigger(id);
|
||||||
trigger = tr;
|
trigger = tr;
|
||||||
seedFormFromConfig(tr);
|
seedTriggerKindFormState(
|
||||||
|
formState,
|
||||||
|
tr.kind,
|
||||||
|
tr.name,
|
||||||
|
tr.config,
|
||||||
|
tr.webhook_enabled,
|
||||||
|
tr.webhook_require_signature
|
||||||
|
);
|
||||||
// Fetch the webhook info only when ingress is enabled —
|
// Fetch the webhook info only when ingress is enabled —
|
||||||
// otherwise the secret/url panel stays in the disabled
|
// otherwise the secret/url panel stays in the disabled
|
||||||
// state. Bindings always load.
|
// state. Bindings always load.
|
||||||
@@ -197,19 +93,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSave = $derived(!!trigger && !saving && isTriggerFormValid(formState));
|
||||||
|
|
||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!trigger || saving) return;
|
if (!trigger || saving || !canSave) return;
|
||||||
if (useAdvancedJson && !jsonValid) return;
|
|
||||||
saving = true;
|
saving = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
|
// Kind is immutable post-create. The form is rendered with
|
||||||
|
// showKindPicker=false so the UI can't mutate it, but pinning
|
||||||
|
// to the loaded value here keeps the contract explicit and
|
||||||
|
// guards against future regressions if someone re-enables
|
||||||
|
// the picker on this page.
|
||||||
const body: TriggerInput = {
|
const body: TriggerInput = {
|
||||||
kind: trigger.kind,
|
...buildTriggerInput(formState),
|
||||||
name: name.trim(),
|
kind: trigger.kind
|
||||||
config: buildConfig(),
|
|
||||||
webhook_enabled: webhookEnabled,
|
|
||||||
webhook_require_signature: webhookRequireSig
|
|
||||||
};
|
};
|
||||||
const updated = await api.updateTrigger(id, body);
|
const updated = await api.updateTrigger(id, body);
|
||||||
trigger = updated;
|
trigger = updated;
|
||||||
@@ -241,6 +140,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let confirmFire = $state(false);
|
||||||
|
let firing = $state(false);
|
||||||
|
let fireResult = $state<{ deployed: number; errored: number; bindings: number } | null>(null);
|
||||||
|
|
||||||
|
// Auto-clear the fire-result flash after a few seconds. Tracked so
|
||||||
|
// a rapid second fire (or component unmount) cancels the prior
|
||||||
|
// timer instead of having two writers race to null/replace state.
|
||||||
|
const FIRE_FLASH_MS = 5000;
|
||||||
|
let fireFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function scheduleFireFlashClear(): void {
|
||||||
|
if (fireFlashTimer !== null) clearTimeout(fireFlashTimer);
|
||||||
|
fireFlashTimer = setTimeout(() => {
|
||||||
|
fireResult = null;
|
||||||
|
fireFlashTimer = null;
|
||||||
|
}, FIRE_FLASH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (fireFlashTimer !== null) clearTimeout(fireFlashTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function doFireNow(): Promise<void> {
|
||||||
|
if (!trigger) return;
|
||||||
|
firing = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await api.fireTriggerNow(trigger.id);
|
||||||
|
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
|
||||||
|
scheduleFireFlashClear();
|
||||||
|
// Refresh the trigger so the "last fired" row reflects the new ts.
|
||||||
|
trigger = await api.getTrigger(trigger.id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fire failed';
|
||||||
|
} finally {
|
||||||
|
firing = false;
|
||||||
|
confirmFire = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doRotate(): Promise<void> {
|
async function doRotate(): Promise<void> {
|
||||||
rotating = true;
|
rotating = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -251,7 +190,8 @@
|
|||||||
webhook = {
|
webhook = {
|
||||||
url: res.url,
|
url: res.url,
|
||||||
secret: res.secret,
|
secret: res.secret,
|
||||||
webhook_require_signature: webhook?.webhook_require_signature ?? webhookRequireSig
|
webhook_require_signature:
|
||||||
|
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Rotate failed';
|
error = e instanceof Error ? e.message : 'Rotate failed';
|
||||||
@@ -377,217 +317,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="field">
|
<!-- Kind picker is hidden on edit — kind is immutable post-
|
||||||
<label for="t-name" class="sub-label">{$t('redeployTriggers.form.name')}</label>
|
create. Everything else (name, kind-aware config form,
|
||||||
<input id="t-name" type="text" class="input" bind:value={name} required />
|
advanced JSON, webhook toggles) lives in the shared
|
||||||
</div>
|
TriggerKindForm so /triggers/new and /triggers/[id]
|
||||||
|
stay in lockstep. -->
|
||||||
<!-- Kind row is read-only — changing kind would invalidate
|
<TriggerKindForm bind:state={formState} idPrefix="t" showKindPicker={false} />
|
||||||
the config payload and isn't supported by the server. -->
|
|
||||||
<div class="field row-meta">
|
|
||||||
<div class="meta-block">
|
|
||||||
<span class="sub-label">{$t('redeployTriggers.form.kindLabel')}</span>
|
|
||||||
<span class="kind-static">
|
|
||||||
<span class="kind-tag mono">{$t(`redeployTriggers.kindShort.${trigger.kind}`)}</span>
|
|
||||||
<span>{kindLabel(trigger.kind)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="adv-toggle"
|
|
||||||
class:on={useAdvancedJson}
|
|
||||||
onclick={() => (useAdvancedJson = !useAdvancedJson)}
|
|
||||||
>
|
|
||||||
{$t('redeployTriggers.form.advancedToggle')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if useAdvancedJson || !isKnownKind(trigger.kind)}
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-json" class="sub-label">{$t('redeployTriggers.form.configJson')}</label>
|
|
||||||
<textarea
|
|
||||||
id="t-json"
|
|
||||||
class="input mono code"
|
|
||||||
class:bad={!jsonValid}
|
|
||||||
bind:value={jsonText}
|
|
||||||
rows="8"
|
|
||||||
spellcheck="false"
|
|
||||||
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}
|
|
||||||
</div>
|
|
||||||
{:else if trigger.kind === 'registry'}
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-image" class="sub-label">{$t('redeployTriggers.form.image')}</label>
|
|
||||||
<input
|
|
||||||
id="t-image"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
bind:value={regImage}
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-tag" class="sub-label">{$t('redeployTriggers.form.tagPattern')}</label>
|
|
||||||
<input
|
|
||||||
id="t-tag"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
bind:value={regTagPattern}
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
|
|
||||||
</div>
|
|
||||||
{:else if trigger.kind === 'git'}
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-repo" class="sub-label">{$t('redeployTriggers.form.repo')}</label>
|
|
||||||
<input
|
|
||||||
id="t-repo"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
bind:value={gitRepo}
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.repoHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<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'}
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-branch" class="sub-label">{$t('redeployTriggers.form.branch')}</label>
|
|
||||||
<input id="t-branch" type="text" class="input mono" bind:value={gitBranch} />
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-gtag" class="sub-label">{$t('redeployTriggers.form.tagPattern')}</label>
|
|
||||||
<input id="t-gtag" type="text" class="input mono" bind:value={gitTagPattern} />
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.tagPatternHint')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if trigger.kind === 'manual'}
|
|
||||||
<div class="note">
|
|
||||||
<span class="note-tag">MANUAL</span>
|
|
||||||
<p>{$t('redeployTriggers.form.manualNote')}</p>
|
|
||||||
</div>
|
|
||||||
{:else if trigger.kind === 'schedule'}
|
|
||||||
<div class="note">
|
|
||||||
<span class="note-tag">CRN</span>
|
|
||||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
|
||||||
<div
|
|
||||||
class="mode-row"
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
|
||||||
>
|
|
||||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={schInterval === p.value}
|
|
||||||
class="mode-chip"
|
|
||||||
class:active={schInterval === p.value}
|
|
||||||
onclick={() => (schInterval = p.value)}
|
|
||||||
>
|
|
||||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-interval" class="sub-label">{$t('redeployTriggers.form.interval')}</label>
|
|
||||||
<input
|
|
||||||
id="t-interval"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
class:bad={!isValidInterval(schInterval)}
|
|
||||||
bind:value={schInterval}
|
|
||||||
placeholder="24h"
|
|
||||||
spellcheck="false"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-schref" class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</label>
|
|
||||||
<input
|
|
||||||
id="t-schref"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
bind:value={schReference}
|
|
||||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field schedule-status">
|
|
||||||
<span class="sub-label">{$t('redeployTriggers.detail.lastFired')}</span>
|
|
||||||
<span class="mono">{formatLastFired(trigger.last_fired_at)}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Webhook ingress toggles live in the same form so a
|
|
||||||
single Save commits the config + ingress state. -->
|
|
||||||
<div class="row-toggle">
|
|
||||||
<div class="toggle-copy">
|
|
||||||
<span class="lbl">{$t('redeployTriggers.detail.webhookEnable')}</span>
|
|
||||||
<p class="hint">{$t('redeployTriggers.detail.webhookEnableHint')}</p>
|
|
||||||
</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
bind:checked={webhookEnabled}
|
|
||||||
label={$t('redeployTriggers.detail.webhookEnable')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if webhookEnabled}
|
|
||||||
<div class="row-toggle indent">
|
|
||||||
<div class="toggle-copy">
|
|
||||||
<span class="lbl">{$t('redeployTriggers.detail.webhookRequireSig')}</span>
|
|
||||||
<p class="hint">{$t('redeployTriggers.detail.webhookRequireSigHint')}</p>
|
|
||||||
</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
bind:checked={webhookRequireSig}
|
|
||||||
label={$t('redeployTriggers.detail.webhookRequireSig')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="forge-btn"
|
class="forge-btn"
|
||||||
disabled={saving || !name.trim() || (useAdvancedJson && !jsonValid)}
|
disabled={!canSave}
|
||||||
aria-busy={saving}
|
aria-busy={saving}
|
||||||
>
|
>
|
||||||
{saving ? $t('observability.saving') : $t('observability.save')}
|
{saving ? $t('observability.saving') : $t('observability.save')}
|
||||||
@@ -595,6 +336,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{#if trigger.kind === 'schedule'}
|
||||||
|
<!-- ── Schedule status panel ─────────────────── -->
|
||||||
|
<section class="panel" aria-labelledby="sched-heading">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h2 class="panel-title" id="sched-heading">
|
||||||
|
{$t('redeployTriggers.detail.scheduleStatus')}<span class="title-accent">.</span>
|
||||||
|
</h2>
|
||||||
|
<span class="panel-sub">{$t('redeployTriggers.detail.scheduleStatusSub')}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sched-row">
|
||||||
|
<div class="sched-block">
|
||||||
|
<span class="sub-label">{$t('redeployTriggers.detail.lastFired')}</span>
|
||||||
|
<span class="mono">{formatLastFired(trigger.last_fired_at)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="forge-btn-ghost xs"
|
||||||
|
onclick={() => (confirmFire = true)}
|
||||||
|
disabled={firing || trigger.binding_count === 0}
|
||||||
|
title={trigger.binding_count === 0
|
||||||
|
? $t('redeployTriggers.detail.fireNowDisabledTitle')
|
||||||
|
: $t('redeployTriggers.detail.fireNowTitle')}
|
||||||
|
>
|
||||||
|
{firing ? $t('redeployTriggers.detail.firing') : $t('redeployTriggers.detail.fireNow')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if fireResult}
|
||||||
|
<div class="fire-flash" role="status">
|
||||||
|
{$t('redeployTriggers.detail.fireResult', {
|
||||||
|
deployed: String(fireResult.deployed),
|
||||||
|
bindings: String(fireResult.bindings),
|
||||||
|
errored: String(fireResult.errored)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Webhook ingress panel ───────────────────── -->
|
<!-- ── Webhook ingress panel ───────────────────── -->
|
||||||
<section class="panel" aria-labelledby="webhook-heading">
|
<section class="panel" aria-labelledby="webhook-heading">
|
||||||
<header class="panel-head">
|
<header class="panel-head">
|
||||||
@@ -699,7 +479,7 @@
|
|||||||
<div class="b-actions">
|
<div class="b-actions">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={b.enabled}
|
checked={b.enabled}
|
||||||
onchange={(next) => toggleBinding(b, next)}
|
onchange={(next: boolean) => toggleBinding(b, next)}
|
||||||
label={$t('redeployTriggers.binding.enabled')}
|
label={$t('redeployTriggers.binding.enabled')}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
@@ -750,7 +530,7 @@
|
|||||||
open={confirmDelete}
|
open={confirmDelete}
|
||||||
title={$t('redeployTriggers.detail.deleteTitle')}
|
title={$t('redeployTriggers.detail.deleteTitle')}
|
||||||
message={$t('redeployTriggers.detail.deleteMessage', {
|
message={$t('redeployTriggers.detail.deleteMessage', {
|
||||||
name: name.trim() || trigger.name,
|
name: formState.name.trim() || trigger.name,
|
||||||
count: String(trigger.binding_count)
|
count: String(trigger.binding_count)
|
||||||
})}
|
})}
|
||||||
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
|
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
|
||||||
@@ -771,6 +551,20 @@
|
|||||||
oncancel={() => (confirmRotate = false)}
|
oncancel={() => (confirmRotate = false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmFire}
|
||||||
|
title={$t('redeployTriggers.detail.fireConfirmTitle')}
|
||||||
|
message={$t('redeployTriggers.detail.fireConfirmMessage', {
|
||||||
|
name: trigger.name,
|
||||||
|
count: String(trigger.binding_count)
|
||||||
|
})}
|
||||||
|
confirmLabel={firing
|
||||||
|
? $t('redeployTriggers.detail.firing')
|
||||||
|
: $t('redeployTriggers.detail.fireConfirm')}
|
||||||
|
onconfirm={doFireNow}
|
||||||
|
oncancel={() => (confirmFire = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmUnbindId !== null}
|
open={confirmUnbindId !== null}
|
||||||
title={$t('redeployTriggers.detail.unbindTitle')}
|
title={$t('redeployTriggers.detail.unbindTitle')}
|
||||||
@@ -887,6 +681,29 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Schedule status panel ─────────────────── */
|
||||||
|
.sched-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sched-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.fire-flash {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: color-mix(in srgb, var(--forge-accent) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--forge-accent) 45%, transparent);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--forge-mono);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Fields ────────────────────────────────────── */
|
/* ── Fields ────────────────────────────────────── */
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -901,36 +718,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.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: 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Hints ─────────────────────────────────────── */
|
/* ── Hints ─────────────────────────────────────── */
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -938,98 +725,8 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.hint.danger { color: var(--color-danger); }
|
|
||||||
.hint.foot { margin-top: 0.6rem; }
|
.hint.foot { margin-top: 0.6rem; }
|
||||||
|
|
||||||
/* ── Read-only kind row + advanced toggle ─────── */
|
|
||||||
.row-meta {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.meta-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.18rem 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;
|
|
||||||
}
|
|
||||||
.adv-toggle {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mode chips ─────────────────────────────────── */
|
|
||||||
.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.3rem 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Note banner ────────────────────────────────── */
|
/* ── Note banner ────────────────────────────────── */
|
||||||
.note {
|
.note {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1060,35 +757,6 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 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;
|
|
||||||
}
|
|
||||||
.lbl {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Actions ────────────────────────────────────── */
|
/* ── Actions ────────────────────────────────────── */
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,172 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type { TriggerInput } from '$lib/api';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import TriggerKindForm, {
|
||||||
|
createTriggerKindFormState,
|
||||||
|
isTriggerFormValid,
|
||||||
|
buildTriggerInput
|
||||||
|
} from '$lib/components/TriggerKindForm.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
// Four kinds have hand-rolled forms today; anything else falls
|
// All kind-aware form state lives in the shared component. The page
|
||||||
// back to the JSON editor. KNOWN_KINDS gates the structured form
|
// just owns submit state + the navigation that follows a successful
|
||||||
// switch — see formNote() for the manual/unknown explainer text.
|
// create. Eliminates the per-kind state slots / buildConfig /
|
||||||
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
|
// canSubmit / template duplication that previously caused regex
|
||||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
// drift between /triggers/new, /triggers/[id], and TriggerKindForm.
|
||||||
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
|
let formState = $state(createTriggerKindFormState({ kind: 'registry' }));
|
||||||
|
|
||||||
// Suggested intervals for schedule triggers. Operators can always
|
|
||||||
// type a custom Go duration ("90m", "1h30m", "168h") into the input.
|
|
||||||
const SCHEDULE_PRESETS = [
|
|
||||||
{ key: 'hourly', value: '1h' },
|
|
||||||
{ key: 'daily', value: '24h' },
|
|
||||||
{ key: 'weekly', value: '168h' }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function isValidInterval(s: string): boolean {
|
|
||||||
const trimmed = s.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
|
||||||
if (single) {
|
|
||||||
const n = parseInt(single[1], 10);
|
|
||||||
const unit = single[2].toLowerCase();
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return false;
|
|
||||||
if (unit === 's' && n < 60) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kind is always one of KNOWN_KINDS — the picker only emits those.
|
|
||||||
// Keeping the literal union (no `| string`) preserves discriminated
|
|
||||||
// narrowing inside buildConfig/canSubmit.
|
|
||||||
let kind = $state<KnownKind>('registry');
|
|
||||||
let name = $state('');
|
|
||||||
let webhookEnabled = $state(false);
|
|
||||||
let webhookRequireSig = $state(true);
|
|
||||||
let useAdvancedJson = $state(false);
|
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
// Per-kind structured fields. They mirror the Go config shapes
|
const canSubmit = $derived(!submitting && isTriggerFormValid(formState));
|
||||||
// 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*');
|
|
||||||
let schInterval = $state('24h');
|
|
||||||
let schReference = $state('');
|
|
||||||
|
|
||||||
// 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 {};
|
|
||||||
case 'schedule': {
|
|
||||||
const ref = schReference.trim();
|
|
||||||
return ref
|
|
||||||
? { interval: schInterval.trim(), reference: ref }
|
|
||||||
: { interval: schInterval.trim() };
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
case 'schedule':
|
|
||||||
return isValidInterval(schInterval);
|
|
||||||
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> {
|
async function submit(e: Event): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit()) return;
|
if (!canSubmit) return;
|
||||||
error = '';
|
error = '';
|
||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
const body: TriggerInput = {
|
const body = buildTriggerInput(formState);
|
||||||
kind,
|
|
||||||
name: name.trim(),
|
|
||||||
config: buildConfig(),
|
|
||||||
webhook_enabled: webhookEnabled,
|
|
||||||
webhook_require_signature: webhookRequireSig
|
|
||||||
};
|
|
||||||
const created = await api.createTrigger(body);
|
const created = await api.createTrigger(body);
|
||||||
goto(`/triggers/${created.id}`);
|
goto(`/triggers/${created.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -175,12 +35,6 @@
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function kindHint(k: string): string {
|
|
||||||
const key = `redeployTriggers.kindHint.${k}`;
|
|
||||||
const v = $t(key);
|
|
||||||
return v === key ? '' : v;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -208,293 +62,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Step 01 · Kind picker. Renders as a grid of square cards
|
<TriggerKindForm bind:state={formState} idPrefix="trig" />
|
||||||
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 if kind === 'schedule'}
|
|
||||||
<div class="note">
|
|
||||||
<span class="note-tag">CRN</span>
|
|
||||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
|
||||||
</div>
|
|
||||||
<div class="sub">
|
|
||||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
|
||||||
<div
|
|
||||||
class="mode-row"
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
|
||||||
>
|
|
||||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={schInterval === p.value}
|
|
||||||
class="mode-chip"
|
|
||||||
class:active={schInterval === p.value}
|
|
||||||
onclick={() => (schInterval = p.value)}
|
|
||||||
>
|
|
||||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="sub" for="trig-interval">
|
|
||||||
<span class="sub-label">{$t('redeployTriggers.form.interval')}</span>
|
|
||||||
<input
|
|
||||||
id="trig-interval"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
class:bad={!isValidInterval(schInterval)}
|
|
||||||
bind:value={schInterval}
|
|
||||||
placeholder="24h"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
|
||||||
</label>
|
|
||||||
<label class="sub" for="trig-schref">
|
|
||||||
<span class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</span>
|
|
||||||
<input
|
|
||||||
id="trig-schref"
|
|
||||||
type="text"
|
|
||||||
class="input mono"
|
|
||||||
bind:value={schReference}
|
|
||||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
|
||||||
</label>
|
|
||||||
{: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">
|
<div class="actions">
|
||||||
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
|
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="forge-btn"
|
class="forge-btn"
|
||||||
disabled={!canSubmit()}
|
disabled={!canSubmit}
|
||||||
aria-busy={submitting}
|
aria-busy={submitting}
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
@@ -529,7 +104,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Alert ─────────────────────────────────────── */
|
|
||||||
.alert {
|
.alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -557,289 +131,6 @@
|
|||||||
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
|
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(4, minmax(0, 1fr));
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.kind-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
Reference in New Issue
Block a user