refactor(triggers): review followups — fire-now, dedupe trigger pages, hardening
Build / build (push) Failing after 34s
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:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вид",
|
||||
|
||||
Reference in New Issue
Block a user