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
+99 -1
View File
@@ -21,9 +21,39 @@
// the type checker — server validation rejects empty ids anyway.
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];
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 webhook = $state<TriggerWebhook | null>(null);
let bindings = $state<TriggerBinding[]>([]);
@@ -56,6 +86,8 @@
let gitMode = $state<'push' | 'tag'>('push');
let gitBranch = $state('main');
let gitTagPattern = $state('v*');
let schInterval = $state('24h');
let schReference = $state('');
let jsonText = $state('');
@@ -106,6 +138,10 @@
case 'manual':
// no fields
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() || '*' };
case 'manual':
return {};
case 'schedule': {
const ref = schReference.trim();
return ref
? { interval: schInterval.trim(), reference: ref }
: { interval: schInterval.trim() };
}
default:
return JSON.parse(jsonText || '{}');
}
@@ -458,6 +500,62 @@
<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
+97 -5
View File
@@ -6,14 +6,39 @@
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
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
// 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];
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 webhookEnabled = $state(false);
let webhookRequireSig = $state(true);
@@ -32,6 +57,8 @@
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
@@ -68,6 +95,12 @@
: { 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
@@ -87,6 +120,8 @@
return !!gitRepo.trim();
case 'manual':
return true;
case 'schedule':
return isValidInterval(schInterval);
default:
return false; // unknown kinds force advanced JSON
}
@@ -361,6 +396,60 @@
<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>
@@ -634,10 +723,13 @@
adds a subtle inner glow so the choice is obvious. */
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
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-card {