refactor(triggers): review followups — fire-now, dedupe trigger pages, hardening
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:
2026-05-16 12:16:47 +03:00
parent 39e1e36510
commit 5e78f13e06
12 changed files with 486 additions and 1227 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ func main() {
switch {
case r.Deployed:
deployed++
case r.Reason == "binding disabled", r.Reason == "no match":
case r.Reason == webhook.ReasonBindingDisabled, r.Reason == webhook.ReasonNoMatch:
// not a failure — silent
default:
errored++
+1
View File
@@ -318,6 +318,7 @@ func (s *Server) Router() chi.Router {
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}/fire", s.fireTriggerNow)
r.Post("/triggers/{id}/bindings", s.bindWorkloadToTrigger)
r.Put("/bindings/{bid}", s.updateBinding)
r.Delete("/bindings/{bid}", s.deleteBinding)
+134
View File
@@ -7,13 +7,27 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
"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
// are never serialized — read them via the dedicated /webhook subresource
// 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)
}
// 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) {
id := chi.URLParam(r, "id")
secret := generateWebhookSecret()
+23 -1
View File
@@ -171,9 +171,31 @@ func (s *Scheduler) shouldFire(t store.Trigger, now time.Time) bool {
// require a manual DB poke.
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.
//
// We persist last_fired_at BEFORE calling the dispatcher so a panic
+11
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"time"
"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
// not bump updated_at — last_fired_at is operational state, while
// 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 {
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(
`UPDATE triggers SET last_fired_at = ? WHERE id = ?`,
ts, id,
+28 -8
View File
@@ -34,6 +34,18 @@ type BindingResult struct {
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
// Trigger record. The secret resolves to one Trigger; the Trigger then
// 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 {
case r.Deployed:
deployed++
case r.Reason == "binding disabled":
case r.Reason == ReasonBindingDisabled:
skipped++
case r.Reason == "no match":
case r.Reason == ReasonNoMatch:
noMatch++
default:
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
// 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
// bindings query fails — both fatal upstream conditions the caller
// 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
for i, b := range bindings {
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
}
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"}
results[i] = BindingResult{Workload: b.WorkloadID, Deployed: false, Reason: ReasonWorkloadMissing}
continue
}
wg.Add(1)
@@ -289,16 +309,16 @@ func (h *Handler) fireBinding(
if err != nil {
slog.Warn("webhook: merge effective trigger config failed",
"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)
if err != nil {
slog.Warn("webhook: trigger match error",
"trigger", trg.Name, "workload", row.Name, "error", err)
return false, "match error"
return false, ReasonMatchError
}
if intent == nil {
return false, "no match"
return false, ReasonNoMatch
}
if intent.TriggeredAt.IsZero() {
intent.TriggeredAt = time.Now().UTC()
@@ -309,7 +329,7 @@ func (h *Handler) fireBinding(
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"
return false, ReasonDispatchFailed
}
slog.Info("webhook: triggered deploy via trigger fan-out",
"trigger", trg.Name, "workload", row.Name, "reason", intent.Reason)
+14
View File
@@ -801,6 +801,20 @@ export function regenerateTriggerWebhook(id: string): Promise<{ secret: string;
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[]> {
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 {
let config: unknown;
if (s.useAdvancedJson) {
+11 -1
View File
@@ -1085,7 +1085,17 @@
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
"unbindConfirm": "Unbind",
"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": {
"kindLabel": "Kind",
+11 -1
View File
@@ -1085,7 +1085,17 @@
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
"unbindConfirm": "Отвязать",
"lastFired": "Последний запуск",
"lastFiredNever": "Ни разу не срабатывал"
"lastFiredNever": "Ни разу не срабатывал",
"scheduleStatus": "Состояние расписания",
"scheduleStatusSub": "Рабочее состояние внутреннего планировщика для этого триггера. «Запустить сейчас» сдвигает следующий запуск и начинает отсчёт нового интервала с этого момента.",
"fireNow": "Запустить сейчас",
"fireNowTitle": "Запустить триггер немедленно и сбросить окно следующего срабатывания.",
"fireNowDisabledTitle": "Привяжите хотя бы одну нагрузку перед запуском.",
"firing": "Запуск…",
"fireConfirmTitle": "Запустить триггер расписания?",
"fireConfirmMessage": "Триггер «{name}» сработает немедленно и развернёт {count} связанных нагрузок. Следующий естественный запуск будет через полный интервал от текущего момента.",
"fireConfirm": "Запустить",
"fireResult": "Сработал · задеплоено {deployed}/{bindings} · ошибок {errored}"
},
"form": {
"kindLabel": "Вид",
+158 -490
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as api from '$lib/api';
@@ -12,6 +12,12 @@
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.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 { t } from '$lib/i18n';
@@ -21,29 +27,6 @@
// the type checker — server validation rejects empty ids anyway.
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 {
if (!ts) return $t('redeployTriggers.detail.lastFiredNever');
const d = new Date(ts);
@@ -70,106 +53,12 @@
let copied = $state(false);
// Form fields. Mirrors the new-page wizard but seeded from
// the loaded trigger's config. The kind field stays read-only
// after creation (changing kind would invalidate the config
// shape and the server doesn't support it).
let name = $state('');
let webhookEnabled = $state(false);
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 || '{}');
}
}
// All kind-aware form state lives in the shared TriggerKindForm
// component. seedTriggerKindFormState() primes it from the loaded
// trigger; buildTriggerInput() reads it back on save. Kind stays
// read-only after creation — TriggerKindForm renders a static
// kind tag when showKindPicker=false.
let formState = $state(createTriggerKindFormState());
async function load(): Promise<void> {
loading = true;
@@ -177,7 +66,14 @@
try {
const tr = await api.getTrigger(id);
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 —
// otherwise the secret/url panel stays in the disabled
// state. Bindings always load.
@@ -197,19 +93,22 @@
}
}
const canSave = $derived(!!trigger && !saving && isTriggerFormValid(formState));
async function save(e?: Event): Promise<void> {
e?.preventDefault();
if (!trigger || saving) return;
if (useAdvancedJson && !jsonValid) return;
if (!trigger || saving || !canSave) return;
saving = true;
error = '';
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 = {
kind: trigger.kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
...buildTriggerInput(formState),
kind: trigger.kind
};
const updated = await api.updateTrigger(id, body);
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> {
rotating = true;
error = '';
@@ -251,7 +190,8 @@
webhook = {
url: res.url,
secret: res.secret,
webhook_require_signature: webhook?.webhook_require_signature ?? webhookRequireSig
webhook_require_signature:
webhook?.webhook_require_signature ?? formState.webhookRequireSig
};
} catch (e) {
error = e instanceof Error ? e.message : 'Rotate failed';
@@ -377,217 +317,18 @@
</span>
</header>
<div class="field">
<label for="t-name" class="sub-label">{$t('redeployTriggers.form.name')}</label>
<input id="t-name" type="text" class="input" bind:value={name} required />
</div>
<!-- Kind row is read-only — changing kind would invalidate
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}
<!-- Kind picker is hidden on edit — kind is immutable post-
create. Everything else (name, kind-aware config form,
advanced JSON, webhook toggles) lives in the shared
TriggerKindForm so /triggers/new and /triggers/[id]
stay in lockstep. -->
<TriggerKindForm bind:state={formState} idPrefix="t" showKindPicker={false} />
<div class="actions">
<button
type="submit"
class="forge-btn"
disabled={saving || !name.trim() || (useAdvancedJson && !jsonValid)}
disabled={!canSave}
aria-busy={saving}
>
{saving ? $t('observability.saving') : $t('observability.save')}
@@ -595,6 +336,45 @@
</div>
</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 ───────────────────── -->
<section class="panel" aria-labelledby="webhook-heading">
<header class="panel-head">
@@ -699,7 +479,7 @@
<div class="b-actions">
<ToggleSwitch
checked={b.enabled}
onchange={(next) => toggleBinding(b, next)}
onchange={(next: boolean) => toggleBinding(b, next)}
label={$t('redeployTriggers.binding.enabled')}
/>
<a
@@ -750,7 +530,7 @@
open={confirmDelete}
title={$t('redeployTriggers.detail.deleteTitle')}
message={$t('redeployTriggers.detail.deleteMessage', {
name: name.trim() || trigger.name,
name: formState.name.trim() || trigger.name,
count: String(trigger.binding_count)
})}
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
@@ -771,6 +551,20 @@
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
open={confirmUnbindId !== null}
title={$t('redeployTriggers.detail.unbindTitle')}
@@ -887,6 +681,29 @@
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 ────────────────────────────────────── */
.field {
display: flex;
@@ -901,36 +718,6 @@
text-transform: uppercase;
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 ─────────────────────────────────────── */
.hint {
font-size: 0.78rem;
@@ -938,98 +725,8 @@
line-height: 1.5;
margin: 0;
}
.hint.danger { color: var(--color-danger); }
.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 {
display: flex;
@@ -1060,35 +757,6 @@
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 {
display: flex;
+16 -725
View File
@@ -1,172 +1,32 @@
<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 TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
buildTriggerInput
} from '$lib/components/TriggerKindForm.svelte';
import { t } from '$lib/i18n';
// Four 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', 'schedule'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
// 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);
// All kind-aware form state lives in the shared component. The page
// just owns submit state + the navigation that follows a successful
// create. Eliminates the per-kind state slots / buildConfig /
// canSubmit / template duplication that previously caused regex
// drift between /triggers/new, /triggers/[id], and TriggerKindForm.
let formState = $state(createTriggerKindFormState({ kind: 'registry' }));
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*');
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();
}
}
}
const canSubmit = $derived(!submitting && isTriggerFormValid(formState));
async function submit(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit()) return;
if (!canSubmit) return;
error = '';
submitting = true;
try {
const body: TriggerInput = {
kind,
name: name.trim(),
config: buildConfig(),
webhook_enabled: webhookEnabled,
webhook_require_signature: webhookRequireSig
};
const body = buildTriggerInput(formState);
const created = await api.createTrigger(body);
goto(`/triggers/${created.id}`);
} catch (e) {
@@ -175,12 +35,6 @@
submitting = false;
}
}
function kindHint(k: string): string {
const key = `redeployTriggers.kindHint.${k}`;
const v = $t(key);
return v === key ? '' : v;
}
</script>
<svelte:head>
@@ -208,293 +62,14 @@
</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 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>
<TriggerKindForm bind:state={formState} idPrefix="trig" />
<div class="actions">
<a href="/triggers" class="forge-btn-ghost">{$t('redeployTriggers.form.cancel')}</a>
<button
type="submit"
class="forge-btn"
disabled={!canSubmit()}
disabled={!canSubmit}
aria-busy={submitting}
>
{submitting
@@ -529,7 +104,6 @@
}
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
@@ -557,289 +131,6 @@
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 {
display: flex;
justify-content: flex-end;