feat(triggers): add schedule trigger kind + internal scheduler
Build / build (push) Successful in 10m42s

Fourth trigger kind alongside registry/git/manual. Recurring time-interval
fires driven by a new internal/scheduler tick loop (default 30s, clamped
to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as
inbound HTTP webhooks, so per-binding concurrency, outcome accounting,
and config-merge semantics are identical.

Schema: triggers.last_fired_at TEXT column (additive ALTER for existing
DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking
Match cannot wedge a tight loop; failed deploys wait one full interval
before retry — correct trade-off for a periodic refresh trigger.

Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the
schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly,
custom interval input matched to Go time.ParseDuration syntax, optional
pinned reference). /triggers/[id] surfaces "last fired" on schedule rows.
EN+RU i18n in parity.

Review fixes from go-reviewer / security-reviewer / typescript-reviewer:
- Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double-
  cancel panic on shutdown re-entry).
- shouldFire rejects sub-MinInterval as defense-in-depth against
  hand-inserted rows that bypassed Validate.
- fire() asserts trigger Kind=="schedule" before dispatching.
- Aligned isValidInterval regex across all three frontend sites; reject
  the unsupported "d" unit (Go time.ParseDuration doesn't accept it).
- formatLastFired falls back to lastFiredNever on malformed timestamps
  rather than leaking raw bytes into the UI.
- main.go scheduler closure logs per-fire deployed/errored counts.
This commit is contained in:
2026-05-16 11:24:05 +03:00
parent e3c7b13d58
commit 39e1e36510
19 changed files with 1247 additions and 49 deletions
+33
View File
@@ -32,10 +32,12 @@ import (
"github.com/alexei/tinyforge/internal/npm" "github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/reconciler" "github.com/alexei/tinyforge/internal/reconciler"
"github.com/alexei/tinyforge/internal/scheduler"
"github.com/alexei/tinyforge/internal/stale" "github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/tinyforge/internal/stats" "github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook" "github.com/alexei/tinyforge/internal/webhook"
"github.com/alexei/tinyforge/internal/workload/plugin"
// Plugin registrations: each blank-import runs its init() and registers // Plugin registrations: each blank-import runs its init() and registers
// itself with internal/workload/plugin. Adding a new Source or Trigger // itself with internal/workload/plugin. Adding a new Source or Trigger
@@ -46,6 +48,7 @@ import (
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git" _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git"
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/manual" _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/manual"
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/registry" _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/registry"
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/schedule"
) )
func main() { func main() {
@@ -178,6 +181,36 @@ func main() {
webhookHandler := webhook.NewHandler(db) webhookHandler := webhook.NewHandler(db)
webhookHandler.SetPluginDispatcher(dep) webhookHandler.SetPluginDispatcher(dep)
// Scheduler ticks every 30s and dispatches "schedule"-kind triggers
// through the same FanOutForTrigger path as the inbound webhook. Boot
// runs one sweep immediately so a daily schedule does not idle 24h
// after a restart before catching up.
sched := scheduler.New(db, func(ctx context.Context, trg store.Trigger, evt plugin.InboundEvent) error {
results, err := webhookHandler.FanOutForTrigger(ctx, trg, evt)
if err != nil {
return err
}
// Log per-fire summary so a schedule that quietly fails on N
// of M bindings is visible without parsing per-binding rows.
var deployed, errored int
for _, r := range results {
switch {
case r.Deployed:
deployed++
case r.Reason == "binding disabled", r.Reason == "no match":
// not a failure — silent
default:
errored++
}
}
slog.Info("scheduler dispatch summary",
"trigger", trg.Name, "bindings", len(results),
"deployed", deployed, "errored", errored)
return nil
}, 30*time.Second)
sched.Start(context.Background())
defer sched.Stop()
// Initialize stale container scanner. // Initialize stale container scanner.
staleScanner := stale.New(db, dockerClient, eventBus) staleScanner := stale.New(db, dockerClient, eventBus)
if err := staleScanner.Start("1h"); err != nil { if err := staleScanner.Start("1h"); err != nil {
+15 -4
View File
@@ -25,10 +25,21 @@ order.
> already had ≥87% coverage from the trigger-split work. > already had ≥87% coverage from the trigger-split work.
> >
> **What's next** is open — the remaining items in the doc are nice-to- > **What's next** is open — the remaining items in the doc are nice-to-
> haves (richer kind-aware UI forms for new trigger kinds; a /triggers > haves (a /triggers deep-link from the proxies page; more compose-source
> deep-link from the proxies page; more compose-source coverage that > coverage that needs a `compose` exec seam). Pick from the task list or
> needs a `compose` exec seam). Pick from the task list or close the > close the arc.
> arc. >
> **Trigger kind expansion (2026-05-16):** added the fourth trigger
> kind, **schedule** — interval-based recurring trigger driven by the
> new `internal/scheduler` tick loop (default 30s, ≤5m). v1 takes a
> Go-duration interval ("24h", "1h", "168h") with a 1-minute floor;
> dispatches through the same `webhook.Handler.FanOutForTrigger` seam
> the inbound HTTP webhook uses, so per-binding concurrency / outcome
> accounting / config-merge semantics are identical. `triggers` gained
> a `last_fired_at` column; the scheduler persists it BEFORE dispatch
> so a panicking Match cannot wedge a tight loop. The frontend
> picker grid grew to four columns and `/triggers/[id]` surfaces
> "last fired" on schedule rows.
## Status at a glance ## Status at a glance
+10 -2
View File
@@ -25,8 +25,14 @@ type triggerView struct {
WebhookEnabled bool `json:"webhook_enabled"` WebhookEnabled bool `json:"webhook_enabled"`
WebhookRequireSignature bool `json:"webhook_require_signature"` WebhookRequireSignature bool `json:"webhook_require_signature"`
BindingCount int `json:"binding_count"` BindingCount int `json:"binding_count"`
CreatedAt string `json:"created_at"` // LastFiredAt is the RFC3339 wall-clock the scheduler last
UpdatedAt string `json:"updated_at"` // dispatched this trigger. Always present in the response shape;
// empty for triggers that have never fired or are not scheduler-
// driven. The detail page renders it as "last fired" on schedule
// triggers; other kinds ignore it.
LastFiredAt string `json:"last_fired_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
} }
func (s *Server) toTriggerView(t store.Trigger) triggerView { func (s *Server) toTriggerView(t store.Trigger) triggerView {
@@ -42,6 +48,7 @@ func (s *Server) toTriggerView(t store.Trigger) triggerView {
WebhookEnabled: t.WebhookSecret != "", WebhookEnabled: t.WebhookSecret != "",
WebhookRequireSignature: t.WebhookRequireSignature, WebhookRequireSignature: t.WebhookRequireSignature,
BindingCount: count, BindingCount: count,
LastFiredAt: t.LastFiredAt,
CreatedAt: t.CreatedAt, CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt, UpdatedAt: t.UpdatedAt,
} }
@@ -59,6 +66,7 @@ func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView {
WebhookEnabled: row.WebhookSecret != "", WebhookEnabled: row.WebhookSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature, WebhookRequireSignature: row.WebhookRequireSignature,
BindingCount: row.BindingCount, BindingCount: row.BindingCount,
LastFiredAt: row.LastFiredAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
} }
+208
View File
@@ -0,0 +1,208 @@
// Package scheduler drives the "schedule" trigger kind. It ticks on a
// fixed interval, scans every enabled schedule trigger, and dispatches
// the ones whose next-fire window has elapsed through the same
// FanOutForTrigger path the inbound HTTP webhook uses.
//
// The scheduler is intentionally simple:
//
// - Tick on `tickInterval` (default 30s).
// - For every trigger with Kind=="schedule", parse its config to get
// the interval, compute (LastFiredAt + interval), and if now >=
// that target, fire.
// - On fire: build a plugin.InboundEvent{Kind: "schedule"} and call
// handler.FanOutForTrigger. last_fired_at is persisted BEFORE the
// dispatch runs so a panicking Match cannot wedge the row into a
// tight retry loop — a failed deploy waits one full interval
// before retry, which is the correct trade-off for a periodic
// refresh trigger.
// - A never-fired trigger (LastFiredAt == "") fires on the next
// tick — operator-friendly for testing "did I configure it right?".
//
// Per-trigger errors are logged but do not abort the tick.
package scheduler
import (
"context"
"log/slog"
"sync"
"time"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
"github.com/alexei/tinyforge/internal/workload/plugin/trigger/schedule"
)
// Scheduler owns the background tick loop.
type Scheduler struct {
store *store.Store
dispatcher fanOutFn
tickInterval time.Duration
clock func() time.Time // overridable for tests
startOnce sync.Once
stopOnce sync.Once
cancel context.CancelFunc
wg sync.WaitGroup
}
// fanOutFn is the internal callback shape — narrower than the public
// FanOutTrigger interface so the wiring in cmd/server/main.go can pass
// a closure directly without standing up a wrapper type.
type fanOutFn func(ctx context.Context, trg store.Trigger, evt plugin.InboundEvent) error
// New constructs a Scheduler bound to `st` that dispatches via `fanOut`.
// `tickInterval` controls how often the loop wakes up to check
// schedules; values <=0 fall back to 30s. Tick intervals longer than 5
// minutes are clamped so a misconfigured value can't silently disable
// schedules.
//
// `fanOut` should call webhook.Handler.FanOutForTrigger and return its
// error (or nil); the per-binding result slice is discarded — the
// scheduler does not need to know per-binding outcomes, only whether
// the dispatch itself failed.
func New(st *store.Store, fanOut fanOutFn, tickInterval time.Duration) *Scheduler {
clamped := tickInterval
if clamped <= 0 {
clamped = 30 * time.Second
}
if clamped > 5*time.Minute {
clamped = 5 * time.Minute
}
if clamped != tickInterval && tickInterval != 0 {
slog.Warn("scheduler: tick interval clamped",
"requested", tickInterval, "applied", clamped)
}
return &Scheduler{
store: st,
dispatcher: fanOut,
tickInterval: clamped,
clock: func() time.Time { return time.Now().UTC() },
}
}
// Start launches the loop. Idempotent — repeat calls are no-ops, not
// goroutine leaks. Mirrors the reconciler's lifecycle.
func (s *Scheduler) Start(ctx context.Context) {
s.startOnce.Do(func() {
ctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.wg.Add(1)
go s.loop(ctx)
})
}
// Stop cancels the context and waits for the in-flight tick. Idempotent
// via sync.Once — second call returns immediately without panicking on
// a double cancel.
func (s *Scheduler) Stop() {
s.stopOnce.Do(func() {
if s.cancel != nil {
s.cancel()
}
})
s.wg.Wait()
}
func (s *Scheduler) loop(ctx context.Context) {
defer s.wg.Done()
// First sweep at boot so a daily schedule does not idle 24h after a
// restart before it picks up rows whose window already elapsed.
s.TickOnce(ctx)
ticker := time.NewTicker(s.tickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.TickOnce(ctx)
}
}
}
// TickOnce runs a single sweep. Exposed for tests and for the boot
// kick. On error per-trigger the loop continues with the next row.
func (s *Scheduler) TickOnce(ctx context.Context) {
rows, err := s.store.ListTriggers("schedule")
if err != nil {
slog.Warn("scheduler: list triggers", "error", err)
return
}
now := s.clock()
for _, t := range rows {
if !s.shouldFire(t, now) {
continue
}
s.fire(ctx, t, now)
}
}
// shouldFire decides whether to dispatch trg at `now`. Returns true if:
// - the trigger's interval is parseable, AND
// - last_fired_at is empty (never fired) OR now >= lastFired + interval.
//
// Unparseable last_fired_at or interval are logged once and treated as
// "do not fire" — the operator needs to fix the config; the scheduler
// must not loop on a broken row.
func (s *Scheduler) shouldFire(t store.Trigger, now time.Time) bool {
interval, err := schedule.IntervalOfRaw(t.Config)
if err != nil {
slog.Warn("scheduler: bad interval", "trigger", t.Name, "error", err)
return false
}
// Defense-in-depth against a hand-inserted row that bypassed
// Validate (manual SQL, restore, ad-hoc migration). Validate
// already enforces the floor on the create path; this re-check
// keeps the loop honest if anything sneaks past it.
if interval < schedule.MinInterval {
slog.Warn("scheduler: interval below minimum, ignoring",
"trigger", t.Name, "interval", interval, "minimum", schedule.MinInterval)
return false
}
if t.LastFiredAt == "" {
return true
}
last, err := time.Parse(time.RFC3339, t.LastFiredAt)
if err != nil {
slog.Warn("scheduler: bad last_fired_at", "trigger", t.Name,
"value", t.LastFiredAt, "error", err)
// Treat as never-fired so the operator's fix-by-redeploy doesn't
// require a manual DB poke.
return true
}
return !now.Before(last.Add(interval))
}
// fire dispatches one trigger and records the new last_fired_at.
//
// We persist last_fired_at BEFORE calling the dispatcher so a panic
// inside Match cannot wedge the row into a tight loop. Down-side: a
// deploy that fails leaves the scheduler waiting one full interval
// before retry — acceptable because the trigger is a periodic refresh,
// not a critical-path retry mechanism.
func (s *Scheduler) fire(ctx context.Context, t store.Trigger, now time.Time) {
// Belt-and-suspenders: ListTriggersByKind only returns "schedule"
// rows, but if a future caller wires fire() differently this guard
// keeps the scheduler from blindly dispatching a kind it isn't
// designed for.
if t.Kind != "schedule" {
slog.Warn("scheduler: refusing to fire non-schedule kind",
"trigger", t.Name, "kind", t.Kind)
return
}
ts := now.Format(time.RFC3339)
if err := s.store.SetTriggerLastFired(t.ID, ts); err != nil {
slog.Warn("scheduler: persist last_fired_at", "trigger", t.Name, "error", err)
return
}
evt := plugin.InboundEvent{
Kind: "schedule",
Schedule: &plugin.ScheduleEvent{FiredAt: now},
}
if err := s.dispatcher(ctx, t, evt); err != nil {
slog.Warn("scheduler: dispatch", "trigger", t.Name, "error", err)
return
}
slog.Info("scheduler: fired", "trigger", t.Name, "kind", t.Kind, "at", ts)
}
+223
View File
@@ -0,0 +1,223 @@
package scheduler
import (
"context"
"testing"
"time"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// newTestStore opens an in-memory SQLite store. Each test gets its own
// DSN so parallel runs do not collide on shared cache databases.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.New(":memory:")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seedScheduleTrigger(t *testing.T, st *store.Store, name, interval, lastFired string) store.Trigger {
t.Helper()
trg, err := st.CreateTrigger(store.Trigger{
Kind: "schedule",
Name: name,
Config: `{"interval":"` + interval + `"}`,
LastFiredAt: lastFired,
})
if err != nil {
t.Fatalf("CreateTrigger: %v", err)
}
return trg
}
func TestShouldFire(t *testing.T) {
st := newTestStore(t)
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
s := New(st, func(context.Context, store.Trigger, plugin.InboundEvent) error { return nil }, 0)
cases := []struct {
name string
interval string
lastFired string
want bool
}{
{"never fired fires", "1h", "", true},
{"window not yet elapsed", "1h", now.Add(-30 * time.Minute).Format(time.RFC3339), false},
{"window exactly elapsed fires", "1h", now.Add(-1 * time.Hour).Format(time.RFC3339), true},
{"window long elapsed fires", "24h", now.Add(-48 * time.Hour).Format(time.RFC3339), true},
{"bad interval suppressed", "banana", "", false},
{"bad last_fired_at treated as never", "1h", "not-a-timestamp", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
trg := store.Trigger{
Config: `{"interval":"` + tc.interval + `"}`,
LastFiredAt: tc.lastFired,
}
got := s.shouldFire(trg, now)
if got != tc.want {
t.Fatalf("shouldFire = %v, want %v", got, tc.want)
}
})
}
}
func TestTickOnce_FiresOverdueTriggers(t *testing.T) {
st := newTestStore(t)
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
// Three triggers: one overdue, one not yet due, one never-fired.
overdue := seedScheduleTrigger(t, st, "overdue", "1h", now.Add(-2*time.Hour).Format(time.RFC3339))
notDue := seedScheduleTrigger(t, st, "notdue", "1h", now.Add(-30*time.Minute).Format(time.RFC3339))
never := seedScheduleTrigger(t, st, "never", "1h", "")
fired := make(map[string]int)
s := New(st, func(_ context.Context, trg store.Trigger, _ plugin.InboundEvent) error {
fired[trg.Name]++
return nil
}, 0)
s.clock = func() time.Time { return now }
s.TickOnce(context.Background())
if fired["overdue"] != 1 {
t.Errorf("overdue should fire once, got %d", fired["overdue"])
}
if fired["notdue"] != 0 {
t.Errorf("notdue should not fire, got %d", fired["notdue"])
}
if fired["never"] != 1 {
t.Errorf("never should fire once on first tick, got %d", fired["never"])
}
// last_fired_at must advance for everyone we dispatched.
for _, id := range []string{overdue.ID, never.ID} {
row, err := st.GetTriggerByID(id)
if err != nil {
t.Fatalf("GetTriggerByID(%s): %v", id, err)
}
if row.LastFiredAt == "" {
t.Errorf("last_fired_at not persisted for %s", row.Name)
}
}
// not-due trigger's last_fired_at must NOT have changed.
row, err := st.GetTriggerByID(notDue.ID)
if err != nil {
t.Fatalf("GetTriggerByID(notdue): %v", err)
}
if row.LastFiredAt != notDue.LastFiredAt {
t.Errorf("notdue last_fired_at changed: was %q now %q", notDue.LastFiredAt, row.LastFiredAt)
}
}
func TestTickOnce_DispatchErrorDoesNotWedgeOthers(t *testing.T) {
st := newTestStore(t)
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
broken := seedScheduleTrigger(t, st, "broken", "1h", "")
seedScheduleTrigger(t, st, "healthy", "1h", "")
fired := map[string]int{}
s := New(st, func(_ context.Context, trg store.Trigger, _ plugin.InboundEvent) error {
fired[trg.Name]++
if trg.Name == "broken" {
return context.Canceled
}
return nil
}, 0)
s.clock = func() time.Time { return now }
s.TickOnce(context.Background())
if fired["broken"] != 1 {
t.Errorf("broken should be attempted once, got %d", fired["broken"])
}
if fired["healthy"] != 1 {
t.Errorf("healthy should fire once, got %d", fired["healthy"])
}
// Core persist-before-dispatch invariant: even though the broken
// trigger's dispatcher returned an error, last_fired_at must have
// advanced. Otherwise the scheduler would re-fire it on every tick.
row, err := st.GetTriggerByID(broken.ID)
if err != nil {
t.Fatalf("GetTriggerByID(broken): %v", err)
}
if row.LastFiredAt == "" {
t.Fatalf("broken trigger last_fired_at must advance even on dispatch error")
}
// And: a second TickOnce at the same `now` must not re-fire broken.
s.TickOnce(context.Background())
if fired["broken"] != 1 {
t.Errorf("broken refired after persist; got %d (want 1)", fired["broken"])
}
}
func TestTickOnce_PersistsLastFiredBeforeDispatch(t *testing.T) {
// Documented behavior: last_fired_at is persisted before the
// dispatcher runs so a panicking match cannot wedge a tight loop.
st := newTestStore(t)
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
trg := seedScheduleTrigger(t, st, "tick", "1h", "")
dispatched := false
s := New(st, func(_ context.Context, t store.Trigger, _ plugin.InboundEvent) error {
// At dispatch time the column must already be set.
row, err := st.GetTriggerByID(t.ID)
if err != nil {
return err
}
dispatched = row.LastFiredAt != ""
return nil
}, 0)
s.clock = func() time.Time { return now }
s.TickOnce(context.Background())
if !dispatched {
t.Fatalf("last_fired_at must be persisted before dispatcher runs")
}
row, err := st.GetTriggerByID(trg.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if row.LastFiredAt != now.Format(time.RFC3339) {
t.Errorf("last_fired_at = %q, want %q", row.LastFiredAt, now.Format(time.RFC3339))
}
}
func TestLifecycle_StartStopIdempotent(t *testing.T) {
// Start + Stop are wrapped in sync.Once. A second call must be a
// no-op (no panic on double-cancel, no goroutine leak from double-
// Start). This guards the shutdown path that runs Stop from both
// defer and the signal-handler block in cmd/server/main.go.
st := newTestStore(t)
noop := func(context.Context, store.Trigger, plugin.InboundEvent) error { return nil }
s := New(st, noop, 100*time.Millisecond)
s.Start(context.Background())
s.Start(context.Background()) // second call: no goroutine spawned
s.Stop()
s.Stop() // second call: no panic on closing already-cancelled context
}
func TestNew_ClampsInterval(t *testing.T) {
st := newTestStore(t)
noop := func(context.Context, store.Trigger, plugin.InboundEvent) error { return nil }
if got := New(st, noop, 0).tickInterval; got != 30*time.Second {
t.Errorf("default = %s, want 30s", got)
}
if got := New(st, noop, 1*time.Hour).tickInterval; got != 5*time.Minute {
t.Errorf("clamped = %s, want 5m", got)
}
if got := New(st, noop, 2*time.Minute).tickInterval; got != 2*time.Minute {
t.Errorf("passthrough = %s, want 2m", got)
}
}
+7 -2
View File
@@ -366,8 +366,13 @@ type Trigger struct {
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"` WebhookRequireSignature bool `json:"webhook_require_signature"`
CreatedAt string `json:"created_at"` // LastFiredAt is the RFC3339 wall-clock the scheduler last dispatched
UpdatedAt string `json:"updated_at"` // this trigger. Empty for never-fired or non-schedule triggers. The
// scheduler reads + writes this column to decide next-fire windows
// and to surface "last fired" on the trigger detail page.
LastFiredAt string `json:"last_fired_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
} }
// WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is // WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is
+6
View File
@@ -164,6 +164,11 @@ func (s *Store) runMigrations() error {
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`, `ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`, `ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
// Schedule trigger needs a column to remember when it last fired so
// the scheduler can compute next-fire windows across restarts.
// Empty string = never fired. Pre-trigger-split DBs land the column
// here so the scheduler can read/write it on first boot.
`ALTER TABLE triggers ADD COLUMN last_fired_at TEXT NOT NULL DEFAULT ''`,
// Hard cutover: drop every legacy table. Idempotent — DROP TABLE // Hard cutover: drop every legacy table. Idempotent — DROP TABLE
// IF EXISTS is a no-op once the table is gone. Operators upgrading // IF EXISTS is a no-op once the table is gone. Operators upgrading
// from a pre-cutover build will lose any project / stack / static // from a pre-cutover build will lose any project / stack / static
@@ -275,6 +280,7 @@ func (s *Store) runMigrations() error {
webhook_secret TEXT NOT NULL DEFAULT '', webhook_secret TEXT NOT NULL DEFAULT '',
webhook_signing_secret TEXT NOT NULL DEFAULT '', webhook_signing_secret TEXT NOT NULL DEFAULT '',
webhook_require_signature INTEGER NOT NULL DEFAULT 0, webhook_require_signature INTEGER NOT NULL DEFAULT 0,
last_fired_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`, )`,
+29 -8
View File
@@ -10,14 +10,14 @@ import (
const triggerColumns = `id, kind, name, config, const triggerColumns = `id, kind, name, config,
webhook_secret, webhook_signing_secret, webhook_require_signature, webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at` last_fired_at, created_at, updated_at`
func scanTrigger(s rowScanner) (Trigger, error) { func scanTrigger(s rowScanner) (Trigger, error) {
var t Trigger var t Trigger
var requireSig int var requireSig int
if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config, if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig, &t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt); err != nil { &t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt); err != nil {
return Trigger{}, err return Trigger{}, err
} }
t.WebhookRequireSignature = requireSig != 0 t.WebhookRequireSignature = requireSig != 0
@@ -38,10 +38,10 @@ func (s *Store) CreateTrigger(t Trigger) (Trigger, error) {
t.UpdatedAt = t.CreatedAt t.UpdatedAt = t.CreatedAt
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO triggers (`+triggerColumns+`) `INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config, t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt, t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
) )
if err != nil { if err != nil {
return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err)) return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
@@ -139,7 +139,7 @@ func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingC
const base = ` const base = `
SELECT t.id, t.kind, t.name, t.config, SELECT t.id, t.kind, t.name, t.config,
t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature, t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature,
t.created_at, t.updated_at, t.last_fired_at, t.created_at, t.updated_at,
COALESCE(b.cnt, 0) COALESCE(b.cnt, 0)
FROM triggers t FROM triggers t
LEFT JOIN ( LEFT JOIN (
@@ -166,7 +166,7 @@ func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingC
var count int var count int
if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config, if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig, &t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt, &count); err != nil { &t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt, &count); err != nil {
return nil, fmt.Errorf("scan trigger+count: %w", err) return nil, fmt.Errorf("scan trigger+count: %w", err)
} }
t.WebhookRequireSignature = requireSig != 0 t.WebhookRequireSignature = requireSig != 0
@@ -236,10 +236,10 @@ func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding)
t.UpdatedAt = t.CreatedAt t.UpdatedAt = t.CreatedAt
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO triggers (`+triggerColumns+`) `INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config, t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt, t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
); err != nil { ); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err)) return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
} }
@@ -301,3 +301,24 @@ func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
} }
return secret, nil return secret, nil
} }
// SetTriggerLastFired records the wall-clock the scheduler last
// dispatched this trigger. Callers pass time.Now().UTC().Format(time.RFC3339)
// 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.
func (s *Store) SetTriggerLastFired(id, ts string) error {
result, err := s.db.Exec(
`UPDATE triggers SET last_fired_at = ? WHERE id = ?`,
ts, id,
)
if err != nil {
return fmt.Errorf("update trigger last_fired_at: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
return nil
}
+38 -8
View File
@@ -25,9 +25,10 @@ import (
// already serializes pulls). // already serializes pulls).
const maxTriggerFanOutConcurrency = 4 const maxTriggerFanOutConcurrency = 4
// bindingResult is the per-binding entry in the trigger fan-out // BindingResult is the per-binding entry in the trigger fan-out
// response body. // response body. Exported so non-HTTP callers (the scheduler) can
type bindingResult struct { // inspect outcomes after calling FanOutForTrigger.
type BindingResult struct {
Workload string `json:"workload"` Workload string `json:"workload"`
Deployed bool `json:"deployed"` Deployed bool `json:"deployed"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
@@ -191,6 +192,35 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
}) })
} }
// FanOutForTrigger looks up the trigger plugin + bindings for trg and
// dispatches evt through the same bounded worker pool the inbound HTTP
// webhook uses. The scheduler calls this on each tick to fire schedule
// triggers without a real HTTP request — same dispatch path, same
// per-binding isolation, same outcome shape.
//
// 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
// with Deployed=false; that case returns nil error.
func (h *Handler) FanOutForTrigger(
ctx context.Context,
trg store.Trigger,
evt plugin.InboundEvent,
) ([]BindingResult, error) {
if h.plugins == nil {
return nil, fmt.Errorf("plugin dispatcher not wired")
}
trigPlugin, err := plugin.GetTrigger(trg.Kind)
if err != nil {
return nil, fmt.Errorf("trigger plugin %q: %w", trg.Kind, err)
}
bindings, err := h.store.ListBindingsForTrigger(trg.ID)
if err != nil {
return nil, fmt.Errorf("list bindings: %w", err)
}
return h.fanOutBindings(ctx, trg, trigPlugin, bindings, evt), nil
}
// fanOutBindings dispatches every binding through fireBinding with at // fanOutBindings dispatches every binding through fireBinding with at
// most maxTriggerFanOutConcurrency goroutines in flight. Order of the // most maxTriggerFanOutConcurrency goroutines in flight. Order of the
// returned slice matches the input bindings slice so callers can rely // returned slice matches the input bindings slice so callers can rely
@@ -205,8 +235,8 @@ func (h *Handler) fanOutBindings(
trigPlugin plugin.Trigger, trigPlugin plugin.Trigger,
bindings []store.WorkloadTriggerBinding, bindings []store.WorkloadTriggerBinding,
evt plugin.InboundEvent, evt plugin.InboundEvent,
) []bindingResult { ) []BindingResult {
results := make([]bindingResult, len(bindings)) results := make([]BindingResult, len(bindings))
concurrency := maxTriggerFanOutConcurrency concurrency := maxTriggerFanOutConcurrency
if len(bindings) < concurrency { if len(bindings) < concurrency {
concurrency = len(bindings) concurrency = len(bindings)
@@ -218,14 +248,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: "binding disabled"}
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: "workload missing"}
continue continue
} }
wg.Add(1) wg.Add(1)
@@ -234,7 +264,7 @@ func (h *Handler) fanOutBindings(
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
fired, reason := h.fireBinding(ctx, trg, trigPlugin, wl, binding, evt) fired, reason := h.fireBinding(ctx, trg, trigPlugin, wl, binding, evt)
results[idx] = bindingResult{Workload: wl.Name, Deployed: fired, Reason: reason} results[idx] = BindingResult{Workload: wl.Name, Deployed: fired, Reason: reason}
}(i, b, row) }(i, b, row)
} }
wg.Wait() wg.Wait()
+1 -1
View File
@@ -61,7 +61,7 @@ type Workload struct {
SourceKind string // "image" | "compose" | "static" | ... SourceKind string // "image" | "compose" | "static" | ...
SourceConfig json.RawMessage // shape determined by SourceKind SourceConfig json.RawMessage // shape determined by SourceKind
TriggerKind string // "registry" | "git" | "manual" | "cron" | ... TriggerKind string // "registry" | "git" | "manual" | "schedule" | ...
TriggerConfig json.RawMessage // shape determined by TriggerKind TriggerConfig json.RawMessage // shape determined by TriggerKind
PublicFaces []PublicFace // zero or more public routes PublicFaces []PublicFace // zero or more public routes
@@ -0,0 +1,129 @@
// Package schedule implements the "schedule" trigger: fires a deploy on
// a recurring time interval driven by the internal scheduler.
//
// v1 is interval-based ("every 24h"). A future revision can add a cron
// expression field; the plugin keeps the JSON shape forward-compatible
// by ignoring unknown keys.
package schedule
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Config is the per-trigger schedule config.
//
// Interval is a Go duration string ("1h", "24h", "168h"). The scheduler
// rejects intervals below MinInterval so a misconfigured trigger cannot
// run away and saturate the docker daemon.
//
// Reference is an optional string passed through to DeploymentIntent so
// the operator can pin a specific tag/sha to redeploy on every tick
// (e.g. always re-pull "stable"). Empty means the Source uses whatever
// it normally would.
type Config struct {
Interval string `json:"interval"`
Reference string `json:"reference,omitempty"`
}
// MinInterval is the floor enforced at Validate time. One minute is a
// pragmatic lower bound: shorter intervals would mostly serve as
// accidental denial-of-service against the deployer.
const MinInterval = time.Minute
type trigger struct{}
func init() { plugin.RegisterTrigger(&trigger{}) }
func (*trigger) Kind() string { return "schedule" }
func (*trigger) SchemaSample() any {
return Config{Interval: "24h"}
}
func (*trigger) Validate(cfg json.RawMessage) error {
if len(cfg) == 0 {
return fmt.Errorf("schedule trigger: config is required")
}
var c Config
if err := json.Unmarshal(cfg, &c); err != nil {
return fmt.Errorf("schedule trigger: invalid json: %w", err)
}
if strings.TrimSpace(c.Interval) == "" {
return fmt.Errorf("schedule trigger: interval is required (e.g. \"24h\")")
}
d, err := ParseInterval(c.Interval)
if err != nil {
return fmt.Errorf("schedule trigger: %w", err)
}
if d < MinInterval {
return fmt.Errorf("schedule trigger: interval %s is below minimum %s",
d, MinInterval)
}
return nil
}
// ParseInterval parses a duration string with the same syntax as
// time.ParseDuration ("90s", "5m", "2h45m"). Exported so the scheduler
// can reuse the same parser and stay consistent with Validate.
func ParseInterval(s string) (time.Duration, error) {
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return 0, fmt.Errorf("invalid interval %q: %w", s, err)
}
if d <= 0 {
return 0, fmt.Errorf("invalid interval %q: must be positive", s)
}
return d, nil
}
// IntervalOf reads the interval out of a workload-effective trigger
// config. Returns the parsed duration; an error if the config is
// missing the interval or malformed. Used by the scheduler when it
// already knows the merged config and wants to schedule next fire.
func IntervalOf(cfg json.RawMessage) (time.Duration, error) {
var c Config
if len(cfg) == 0 {
return 0, fmt.Errorf("schedule trigger: empty config")
}
if err := json.Unmarshal(cfg, &c); err != nil {
return 0, fmt.Errorf("schedule trigger: invalid json: %w", err)
}
return ParseInterval(c.Interval)
}
// IntervalOfRaw is a convenience that decodes the raw trigger.config
// blob from store.Trigger (a JSON string). Wrapper kept tiny because
// the scheduler holds the raw string, not a parsed shape.
func IntervalOfRaw(raw string) (time.Duration, error) {
return IntervalOf(json.RawMessage(raw))
}
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
if evt.Kind != "schedule" || evt.Schedule == nil {
return nil, nil
}
cfg, err := plugin.TriggerConfigOf[Config](w)
if err != nil {
return nil, fmt.Errorf("schedule trigger: decode config: %w", err)
}
firedAt := evt.Schedule.FiredAt
if firedAt.IsZero() {
firedAt = time.Now().UTC()
}
meta := map[string]string{
"interval": cfg.Interval,
}
return &plugin.DeploymentIntent{
Reason: "schedule",
Reference: strings.TrimSpace(cfg.Reference),
Metadata: meta,
TriggeredAt: firedAt,
TriggeredBy: "scheduler",
}, nil
}
@@ -0,0 +1,197 @@
package schedule
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
func TestValidate(t *testing.T) {
tr := &trigger{}
cases := []struct {
name string
cfg json.RawMessage
wantErr string // substring; empty = expect success
}{
{"empty body rejected", nil, "config is required"},
{"empty object rejected", json.RawMessage(`{}`), "interval is required"},
{"missing interval rejected", json.RawMessage(`{"reference":"v1"}`), "interval is required"},
{"invalid json rejected", json.RawMessage(`not json`), "invalid json"},
{"unparseable interval rejected", json.RawMessage(`{"interval":"banana"}`), "invalid interval"},
{"zero interval rejected", json.RawMessage(`{"interval":"0s"}`), "must be positive"},
{"sub-minute rejected", json.RawMessage(`{"interval":"30s"}`), "below minimum"},
{"exact minimum accepted", json.RawMessage(`{"interval":"1m"}`), ""},
{"hour accepted", json.RawMessage(`{"interval":"1h"}`), ""},
{"day accepted", json.RawMessage(`{"interval":"24h"}`), ""},
{"unknown keys tolerated", json.RawMessage(`{"interval":"1h","future_field":42}`), ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tr.Validate(tc.cfg)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("expected nil err, got %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error %q missing substring %q", err.Error(), tc.wantErr)
}
})
}
}
func TestParseInterval(t *testing.T) {
cases := []struct {
in string
want time.Duration
err bool
}{
{"1h", time.Hour, false},
{"24h", 24 * time.Hour, false},
{" 5m ", 5 * time.Minute, false},
{"168h", 7 * 24 * time.Hour, false},
{"", 0, true},
{"banana", 0, true},
{"-1h", 0, true},
{"0s", 0, true},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got, err := ParseInterval(tc.in)
if tc.err {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tc.want {
t.Fatalf("got %s, want %s", got, tc.want)
}
})
}
}
func TestIntervalOfRaw(t *testing.T) {
d, err := IntervalOfRaw(`{"interval":"30m"}`)
if err != nil {
t.Fatalf("err: %v", err)
}
if d != 30*time.Minute {
t.Fatalf("got %s, want 30m", d)
}
if _, err := IntervalOfRaw(""); err == nil {
t.Fatalf("expected error on empty raw")
}
}
func TestMatch_WrongKindIgnored(t *testing.T) {
tr := &trigger{}
wl := plugin.Workload{ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`)}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
plugin.InboundEvent{Kind: "manual"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if intent != nil {
t.Fatalf("expected nil intent for wrong kind, got %+v", intent)
}
}
func TestMatch_MissingSchedulePayload(t *testing.T) {
tr := &trigger{}
wl := plugin.Workload{ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`)}
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
plugin.InboundEvent{Kind: "schedule"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if intent != nil {
t.Fatalf("expected nil intent when payload missing, got %+v", intent)
}
}
func TestMatch_SchedulePopulatesIntent(t *testing.T) {
tr := &trigger{}
wl := plugin.Workload{
ID: "w1",
TriggerConfig: json.RawMessage(`{"interval":"6h","reference":"stable"}`),
}
fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
plugin.InboundEvent{
Kind: "schedule",
Schedule: &plugin.ScheduleEvent{FiredAt: fixed},
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if intent == nil {
t.Fatalf("expected intent")
}
if intent.Reason != "schedule" {
t.Errorf("Reason = %q, want schedule", intent.Reason)
}
if intent.Reference != "stable" {
t.Errorf("Reference = %q, want stable", intent.Reference)
}
if intent.TriggeredBy != "scheduler" {
t.Errorf("TriggeredBy = %q, want scheduler", intent.TriggeredBy)
}
if !intent.TriggeredAt.Equal(fixed) {
t.Errorf("TriggeredAt = %s, want %s", intent.TriggeredAt, fixed)
}
if intent.Metadata["interval"] != "6h" {
t.Errorf("interval metadata = %q, want 6h", intent.Metadata["interval"])
}
}
func TestMatch_ZeroFiredAtFallsBackToNow(t *testing.T) {
tr := &trigger{}
wl := plugin.Workload{
ID: "w1",
TriggerConfig: json.RawMessage(`{"interval":"1h"}`),
}
before := time.Now().UTC()
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
plugin.InboundEvent{
Kind: "schedule",
Schedule: &plugin.ScheduleEvent{},
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
after := time.Now().UTC()
if intent.TriggeredAt.Before(before) || intent.TriggeredAt.After(after.Add(time.Second)) {
t.Errorf("TriggeredAt %s not in [%s, %s]", intent.TriggeredAt, before, after)
}
}
func TestKindAndSchemaSample(t *testing.T) {
tr := &trigger{}
if tr.Kind() != "schedule" {
t.Fatalf("Kind = %q, want schedule", tr.Kind())
}
type withSample interface{ SchemaSample() any }
s, ok := any(tr).(withSample)
if !ok {
t.Fatalf("trigger does not expose SchemaSample()")
}
sample, ok := s.SchemaSample().(Config)
if !ok {
t.Fatalf("SchemaSample is not Config: %T", s.SchemaSample())
}
if _, err := ParseInterval(sample.Interval); err != nil {
t.Errorf("SchemaSample.Interval is not parseable: %v", err)
}
}
+20 -11
View File
@@ -29,18 +29,19 @@ type PublicFace struct {
EnableSSL bool EnableSSL bool
} }
// InboundEvent is what an upstream signal (webhook, poll, manual click) // InboundEvent is what an upstream signal (webhook, poll, manual click,
// looks like to a Trigger.Match call. Triggers consult Kind first to // scheduler tick) looks like to a Trigger.Match call. Triggers consult
// decide whether the event is interesting, then read the matching payload // Kind first to decide whether the event is interesting, then read the
// field. RawBody / Headers are kept so trigger plugins can perform their // matching payload field. RawBody / Headers are kept so trigger plugins
// own signature verification or vendor-specific parsing. // can perform their own signature verification or vendor-specific parsing.
type InboundEvent struct { type InboundEvent struct {
Kind string // "image-push" | "git-push" | "git-tag" | "manual" | "cron-tick" Kind string // "image-push" | "git-push" | "git-tag" | "manual" | "schedule"
Image *ImagePushEvent Image *ImagePushEvent
Git *GitEvent Git *GitEvent
Manual *ManualEvent Manual *ManualEvent
RawBody []byte Schedule *ScheduleEvent
Headers map[string][]string RawBody []byte
Headers map[string][]string
} }
// ImagePushEvent is normalized across registry vendors (generic, Gitea, // ImagePushEvent is normalized across registry vendors (generic, Gitea,
@@ -72,6 +73,14 @@ type ManualEvent struct {
Note string Note string
} }
// ScheduleEvent is fired by the internal scheduler when a schedule
// trigger's next-fire window is reached. FiredAt is the wall-clock the
// scheduler observed (already truncated to the second). The trigger
// plugin uses FiredAt + its own config to populate the DeploymentIntent.
type ScheduleEvent struct {
FiredAt time.Time
}
// SourceConfigOf decodes the workload's SourceConfig blob into the typed // SourceConfigOf decodes the workload's SourceConfig blob into the typed
// shape a specific Source uses. Kept here so callers do not duplicate the // shape a specific Source uses. Kept here so callers do not duplicate the
// boilerplate. // boilerplate.
+3
View File
@@ -719,6 +719,9 @@ export interface RedeployTrigger {
webhook_enabled: boolean; webhook_enabled: boolean;
webhook_require_signature: boolean; webhook_require_signature: boolean;
binding_count: number; binding_count: number;
/** RFC3339 timestamp the scheduler last dispatched this trigger. Empty for
* never-fired or non-scheduler-driven triggers. */
last_fired_at: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+102 -5
View File
@@ -18,9 +18,17 @@
<script lang="ts" module> <script lang="ts" module>
import type { TriggerInput } from '$lib/api'; import type { TriggerInput } from '$lib/api';
export const KNOWN_KINDS = ['registry', 'git', 'manual'] as const; export const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
export type KnownTriggerKind = (typeof KNOWN_KINDS)[number]; export type KnownTriggerKind = (typeof KNOWN_KINDS)[number];
/** Suggested intervals offered as chips in the schedule form. Operator
* can always type a custom Go duration into the input. */
export const SCHEDULE_PRESETS = [
{ key: 'hourly', value: '1h' },
{ key: 'daily', value: '24h' },
{ key: 'weekly', value: '168h' }
] as const;
/** /**
* State shared between the component and its parent. The parent owns * State shared between the component and its parent. The parent owns
* one of these and binds it; the component mutates fields in place * one of these and binds it; the component mutates fields in place
@@ -41,6 +49,9 @@
gitMode: 'push' | 'tag'; gitMode: 'push' | 'tag';
gitBranch: string; gitBranch: string;
gitTagPattern: string; gitTagPattern: string;
// schedule
schInterval: string;
schReference: string;
// JSON fallback // JSON fallback
jsonText: string; jsonText: string;
} }
@@ -60,12 +71,32 @@
gitMode: init.gitMode ?? 'push', gitMode: init.gitMode ?? 'push',
gitBranch: init.gitBranch ?? 'main', gitBranch: init.gitBranch ?? 'main',
gitTagPattern: init.gitTagPattern ?? 'v*', gitTagPattern: init.gitTagPattern ?? 'v*',
schInterval: init.schInterval ?? '24h',
schReference: init.schReference ?? '',
jsonText: init.jsonText ?? '' jsonText: init.jsonText ?? ''
}; };
} }
function isKnownKind(k: string): k is KnownTriggerKind { /** Matches MinInterval enforced by the schedule trigger plugin
return (KNOWN_KINDS as readonly string[]).includes(k); * (internal/workload/plugin/trigger/schedule). Validation that mirrors
* the backend rule keeps the submit button accurate. Go's
* time.ParseDuration accepts s/m/h (NOT d) — keep this in sync to
* avoid submit-then-server-reject. */
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;
}
// Compound like "1h30m" / "90m". Tighten the fallback so we
// don't green-light "1 h" (whitespace inside), "-1h" (negative),
// or "1.h" — Go's time.ParseDuration rejects all of those.
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
} }
export function isTriggerFormValid(s: TriggerKindFormState): boolean { export function isTriggerFormValid(s: TriggerKindFormState): boolean {
@@ -86,6 +117,8 @@
return !!s.gitRepo.trim(); return !!s.gitRepo.trim();
case 'manual': case 'manual':
return true; return true;
case 'schedule':
return isValidInterval(s.schInterval);
default: default:
// Unknown kinds without an advanced JSON payload are unsubmittable. // Unknown kinds without an advanced JSON payload are unsubmittable.
return false; return false;
@@ -112,6 +145,11 @@
}; };
} else if (s.kind === 'manual') { } else if (s.kind === 'manual') {
config = {}; config = {};
} else if (s.kind === 'schedule') {
const ref = s.schReference.trim();
config = ref
? { interval: s.schInterval.trim(), reference: ref }
: { interval: s.schInterval.trim() };
} else { } else {
config = {}; config = {};
} }
@@ -379,6 +417,60 @@
<span class="note-tag">MANUAL</span> <span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p> <p>{$t('redeployTriggers.form.manualNote')}</p>
</div> </div>
{:else if state.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={state.schInterval === p.value}
class="mode-chip"
class:active={state.schInterval === p.value}
onclick={() => (state.schInterval = p.value)}
>
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
</button>
{/each}
</div>
</div>
<label class="sub" for="{idPrefix}-interval">
<span class="sub-label">{$t('redeployTriggers.form.interval')}</span>
<input
id="{idPrefix}-interval"
type="text"
class="input mono"
class:bad={!isValidInterval(state.schInterval)}
bind:value={state.schInterval}
placeholder="24h"
autocomplete="off"
spellcheck="false"
required
/>
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
</label>
<label class="sub" for="{idPrefix}-schref">
<span class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</span>
<input
id="{idPrefix}-schref"
type="text"
class="input mono"
bind:value={state.schReference}
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
</label>
{:else} {:else}
<div class="note"> <div class="note">
<span class="note-tag">?</span> <span class="note-tag">?</span>
@@ -543,10 +635,15 @@
.kind-grid { .kind-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem; gap: 0.55rem;
} }
@media (max-width: 600px) { @media (max-width: 900px) {
.kind-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.kind-grid { .kind-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+15 -1
View File
@@ -1083,7 +1083,9 @@
"rotateConfirm": "Rotate now", "rotateConfirm": "Rotate now",
"unbindTitle": "Unbind workload?", "unbindTitle": "Unbind workload?",
"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",
"lastFiredNever": "Never fired"
}, },
"form": { "form": {
"kindLabel": "Kind", "kindLabel": "Kind",
@@ -1108,6 +1110,18 @@
"branchPlaceholder": "main", "branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.", "branchHint": "Only push events advancing this branch fire the trigger.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.", "manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"scheduleNote": "Fires on a fixed interval driven by Tinyforge's internal scheduler. No external webhook is required — enable the webhook ingress below only if a CI also needs to fire it on demand.",
"intervalPresets": "Quick presets",
"intervalPreset": {
"hourly": "Hourly",
"daily": "Daily",
"weekly": "Weekly"
},
"interval": "Interval",
"intervalHint": "Go duration (e.g. \"30m\", \"6h\", \"24h\", \"168h\"). Minimum 1 minute.",
"scheduleReference": "Pinned reference (optional)",
"scheduleReferencePlaceholder": "stable",
"scheduleReferenceHint": "Optional tag, branch, or revision the source plugin should re-pull each fire. Leave empty to let the source use its default.",
"unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.", "unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.",
"advancedToggle": "Advanced JSON", "advancedToggle": "Advanced JSON",
"advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.", "advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.",
+15 -1
View File
@@ -1083,7 +1083,9 @@
"rotateConfirm": "Сменить", "rotateConfirm": "Сменить",
"unbindTitle": "Отвязать нагрузку?", "unbindTitle": "Отвязать нагрузку?",
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.", "unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
"unbindConfirm": "Отвязать" "unbindConfirm": "Отвязать",
"lastFired": "Последний запуск",
"lastFiredNever": "Ни разу не срабатывал"
}, },
"form": { "form": {
"kindLabel": "Вид", "kindLabel": "Вид",
@@ -1108,6 +1110,18 @@
"branchPlaceholder": "main", "branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.", "branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.", "manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
"intervalPresets": "Быстрые пресеты",
"intervalPreset": {
"hourly": "Каждый час",
"daily": "Каждый день",
"weekly": "Каждую неделю"
},
"interval": "Интервал",
"intervalHint": "Длительность в формате Go (например «30m», «6h», «24h», «168h»). Минимум 1 минута.",
"scheduleReference": "Фиксированная ссылка (опционально)",
"scheduleReferencePlaceholder": "stable",
"scheduleReferenceHint": "Опциональный тег, ветка или ревизия, которые источник будет подтягивать на каждом срабатывании. Оставьте пустым, чтобы использовать значение по умолчанию.",
"unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.", "unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.",
"advancedToggle": "Расширенный JSON", "advancedToggle": "Расширенный JSON",
"advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.", "advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.",
+99 -1
View File
@@ -21,9 +21,39 @@
// 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'] as const; const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number]; 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);
// Defensive: a malformed timestamp from a future writer should
// not leak raw bytes into the UI. Fall back to the never-fired
// label rather than render an unparseable string.
if (Number.isNaN(d.getTime())) return $t('redeployTriggers.detail.lastFiredNever');
return d.toLocaleString();
}
let trigger = $state<RedeployTrigger | null>(null); let trigger = $state<RedeployTrigger | null>(null);
let webhook = $state<TriggerWebhook | null>(null); let webhook = $state<TriggerWebhook | null>(null);
let bindings = $state<TriggerBinding[]>([]); let bindings = $state<TriggerBinding[]>([]);
@@ -56,6 +86,8 @@
let gitMode = $state<'push' | 'tag'>('push'); let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main'); let gitBranch = $state('main');
let gitTagPattern = $state('v*'); let gitTagPattern = $state('v*');
let schInterval = $state('24h');
let schReference = $state('');
let jsonText = $state(''); let jsonText = $state('');
@@ -106,6 +138,10 @@
case 'manual': case 'manual':
// no fields // no fields
break; break;
case 'schedule':
schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h';
schReference = typeof cfg.reference === 'string' ? cfg.reference : '';
break;
} }
} }
@@ -124,6 +160,12 @@
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' }; : { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual': case 'manual':
return {}; return {};
case 'schedule': {
const ref = schReference.trim();
return ref
? { interval: schInterval.trim(), reference: ref }
: { interval: schInterval.trim() };
}
default: default:
return JSON.parse(jsonText || '{}'); return JSON.parse(jsonText || '{}');
} }
@@ -458,6 +500,62 @@
<span class="note-tag">MANUAL</span> <span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p> <p>{$t('redeployTriggers.form.manualNote')}</p>
</div> </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} {/if}
<!-- Webhook ingress toggles live in the same form so a <!-- Webhook ingress toggles live in the same form so a
+97 -5
View File
@@ -6,14 +6,39 @@
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
// Three kinds have hand-rolled forms today; anything else falls // Four kinds have hand-rolled forms today; anything else falls
// back to the JSON editor. KNOWN_KINDS gates the structured form // back to the JSON editor. KNOWN_KINDS gates the structured form
// switch — see formNote() for the manual/unknown explainer text. // switch — see formNote() for the manual/unknown explainer text.
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const; const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
type KnownKind = (typeof KNOWN_KINDS)[number]; type KnownKind = (typeof KNOWN_KINDS)[number];
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS; const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
let kind = $state<KnownKind | string>('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 name = $state('');
let webhookEnabled = $state(false); let webhookEnabled = $state(false);
let webhookRequireSig = $state(true); let webhookRequireSig = $state(true);
@@ -32,6 +57,8 @@
let gitMode = $state<'push' | 'tag'>('push'); let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main'); let gitBranch = $state('main');
let gitTagPattern = $state('v*'); let gitTagPattern = $state('v*');
let schInterval = $state('24h');
let schReference = $state('');
// Advanced JSON editor — primed with the sample shape for the // Advanced JSON editor — primed with the sample shape for the
// current kind on first toggle so the operator has something to // current kind on first toggle so the operator has something to
@@ -68,6 +95,12 @@
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' }; : { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
case 'manual': case 'manual':
return {}; return {};
case 'schedule': {
const ref = schReference.trim();
return ref
? { interval: schInterval.trim(), reference: ref }
: { interval: schInterval.trim() };
}
default: default:
// Unknown kind reached the structured path — fall back // Unknown kind reached the structured path — fall back
// to an empty object; advanced JSON would normally be // to an empty object; advanced JSON would normally be
@@ -87,6 +120,8 @@
return !!gitRepo.trim(); return !!gitRepo.trim();
case 'manual': case 'manual':
return true; return true;
case 'schedule':
return isValidInterval(schInterval);
default: default:
return false; // unknown kinds force advanced JSON return false; // unknown kinds force advanced JSON
} }
@@ -361,6 +396,60 @@
<span class="note-tag">MANUAL</span> <span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p> <p>{$t('redeployTriggers.form.manualNote')}</p>
</div> </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} {:else}
<div class="note"> <div class="note">
<span class="note-tag">?</span> <span class="note-tag">?</span>
@@ -634,10 +723,13 @@
adds a subtle inner glow so the choice is obvious. */ adds a subtle inner glow so the choice is obvious. */
.kind-grid { .kind-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem; gap: 0.6rem;
} }
@media (max-width: 600px) { @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-grid { grid-template-columns: 1fr; }
} }
.kind-card { .kind-card {