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
+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": "Вид",