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
+3
View File
@@ -719,6 +719,9 @@ export interface RedeployTrigger {
webhook_enabled: boolean;
webhook_require_signature: boolean;
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;
updated_at: string;
}
+102 -5
View File
@@ -18,9 +18,17 @@
<script lang="ts" module>
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];
/** 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
* one of these and binds it; the component mutates fields in place
@@ -41,6 +49,9 @@
gitMode: 'push' | 'tag';
gitBranch: string;
gitTagPattern: string;
// schedule
schInterval: string;
schReference: string;
// JSON fallback
jsonText: string;
}
@@ -60,12 +71,32 @@
gitMode: init.gitMode ?? 'push',
gitBranch: init.gitBranch ?? 'main',
gitTagPattern: init.gitTagPattern ?? 'v*',
schInterval: init.schInterval ?? '24h',
schReference: init.schReference ?? '',
jsonText: init.jsonText ?? ''
};
}
function isKnownKind(k: string): k is KnownTriggerKind {
return (KNOWN_KINDS as readonly string[]).includes(k);
/** Matches MinInterval enforced by the schedule trigger plugin
* (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 {
@@ -86,6 +117,8 @@
return !!s.gitRepo.trim();
case 'manual':
return true;
case 'schedule':
return isValidInterval(s.schInterval);
default:
// Unknown kinds without an advanced JSON payload are unsubmittable.
return false;
@@ -112,6 +145,11 @@
};
} else if (s.kind === 'manual') {
config = {};
} else if (s.kind === 'schedule') {
const ref = s.schReference.trim();
config = ref
? { interval: s.schInterval.trim(), reference: ref }
: { interval: s.schInterval.trim() };
} else {
config = {};
}
@@ -379,6 +417,60 @@
<span class="note-tag">MANUAL</span>
<p>{$t('redeployTriggers.form.manualNote')}</p>
</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}
<div class="note">
<span class="note-tag">?</span>
@@ -543,10 +635,15 @@
.kind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
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 {
grid-template-columns: 1fr;
}
+15 -1
View File
@@ -1083,7 +1083,9 @@
"rotateConfirm": "Rotate now",
"unbindTitle": "Unbind workload?",
"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": {
"kindLabel": "Kind",
@@ -1108,6 +1110,18 @@
"branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"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.",
"advancedToggle": "Advanced JSON",
"advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.",
+15 -1
View File
@@ -1083,7 +1083,9 @@
"rotateConfirm": "Сменить",
"unbindTitle": "Отвязать нагрузку?",
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
"unbindConfirm": "Отвязать"
"unbindConfirm": "Отвязать",
"lastFired": "Последний запуск",
"lastFiredNever": "Ни разу не срабатывал"
},
"form": {
"kindLabel": "Вид",
@@ -1108,6 +1110,18 @@
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"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-редактор ниже; сервер валидирует форму.",
"advancedToggle": "Расширенный JSON",
"advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.",