feat(triggers): start alert workflow after consecutive cron/http failures
Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu"
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import cronstrue from "cronstrue";
|
||||
import { namespacedPath, HTTP_METHODS } from "../../lib/workflow-doc.js";
|
||||
import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js";
|
||||
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
|
||||
|
||||
export function TriggerCard({
|
||||
@@ -15,6 +15,8 @@ export function TriggerCard({
|
||||
disabled,
|
||||
auths = [],
|
||||
pages = [],
|
||||
workflows = [],
|
||||
excludeFile,
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: trigger.uiId,
|
||||
@@ -27,6 +29,7 @@ export function TriggerCard({
|
||||
};
|
||||
const type = trigger.type;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const alertDestinations = triggerDestinations(workflows, { owner, excludeFile });
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -85,10 +88,16 @@ export function TriggerCard({
|
||||
onChange={onChange}
|
||||
auths={auths}
|
||||
pages={pages}
|
||||
alertDestinations={alertDestinations}
|
||||
/>
|
||||
) : null}
|
||||
{type === "cron" ? (
|
||||
<CronFields trigger={trigger} disabled={disabled} onChange={onChange} />
|
||||
<CronFields
|
||||
trigger={trigger}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
alertDestinations={alertDestinations}
|
||||
/>
|
||||
) : null}
|
||||
{type === "workflow" ? (
|
||||
<p className="text-sm opacity-80">
|
||||
@@ -111,7 +120,13 @@ function triggerSummary(trigger, owner) {
|
||||
if (type === "HTTP") {
|
||||
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}`;
|
||||
}
|
||||
if (type === "cron") return trigger.schedule || "";
|
||||
if (type === "cron") {
|
||||
const extra =
|
||||
trigger.onConsecutiveFailures && trigger.triggerWorkflow
|
||||
? ` · alert@${trigger.triggerWorkflow}`
|
||||
: "";
|
||||
return `${trigger.schedule || ""}${extra}`;
|
||||
}
|
||||
if (type === "workflow") return "callable";
|
||||
return "";
|
||||
}
|
||||
@@ -123,7 +138,7 @@ function typeLabel(type) {
|
||||
return type || "Trigger";
|
||||
}
|
||||
|
||||
function HttpFields({ trigger, owner, disabled, onChange, auths, pages }) {
|
||||
function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDestinations }) {
|
||||
const path = trigger.path || "/";
|
||||
const url = namespacedPath(owner || "owner", path);
|
||||
const authIsInline = trigger.auth != null && typeof trigger.auth === "object";
|
||||
@@ -218,11 +233,17 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages }) {
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
<FailureAlertFields
|
||||
trigger={trigger}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
alertDestinations={alertDestinations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CronFields({ trigger, disabled, onChange }) {
|
||||
function CronFields({ trigger, disabled, onChange, alertDestinations }) {
|
||||
const preset = matchCronPreset(trigger.schedule);
|
||||
let human = "";
|
||||
try {
|
||||
@@ -269,6 +290,62 @@ function CronFields({ trigger, disabled, onChange }) {
|
||||
Cron runs use this workflow's top-level <span className="font-mono">data</span> as the payload.
|
||||
Set it in YAML or the Test panel prefill.
|
||||
</p>
|
||||
<FailureAlertFields
|
||||
trigger={trigger}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
alertDestinations={alertDestinations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureAlertFields({ trigger, disabled, onChange, alertDestinations }) {
|
||||
return (
|
||||
<div className="space-y-2 border-t border-base-300 pt-3">
|
||||
<p className="text-xs font-medium opacity-80">Failure alert</p>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Consecutive failures</span>
|
||||
<input
|
||||
className="input input-sm"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={trigger.onConsecutiveFailures ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...trigger, onConsecutiveFailures: e.target.value })
|
||||
}
|
||||
placeholder="e.g. 3"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Trigger workflow</span>
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={trigger.triggerWorkflow ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...trigger, triggerWorkflow: e.target.value })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{alertDestinations.map((w) => (
|
||||
<option key={`${w.owner}/${w.file}`} value={w.name ?? w.file}>
|
||||
{w.name ?? w.file}
|
||||
</option>
|
||||
))}
|
||||
{trigger.triggerWorkflow &&
|
||||
!alertDestinations.some(
|
||||
(w) => (w.name ?? w.file) === trigger.triggerWorkflow,
|
||||
) ? (
|
||||
<option value={trigger.triggerWorkflow}>{trigger.triggerWorkflow}</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs opacity-60">
|
||||
After this many sequential failed runs for this trigger, JerapahFlow starts the
|
||||
selected workflow (it must declare a <span className="font-mono">workflow</span>{" "}
|
||||
trigger).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export function TriggersTab({
|
||||
owner,
|
||||
auths = [],
|
||||
pages = [],
|
||||
workflows = [],
|
||||
excludeFile,
|
||||
}) {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const sensors = useSensors(
|
||||
@@ -77,6 +79,8 @@ export function TriggersTab({
|
||||
owner={owner}
|
||||
auths={auths}
|
||||
pages={pages}
|
||||
workflows={workflows}
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
const copy = [...triggers];
|
||||
|
||||
@@ -188,6 +188,8 @@ export function WorkflowVisualEditor({
|
||||
owner={owner}
|
||||
auths={auths}
|
||||
pages={pages}
|
||||
workflows={workflows}
|
||||
excludeFile={file}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "yaml" ? (
|
||||
|
||||
@@ -142,6 +142,8 @@ export function newHttpTrigger() {
|
||||
auth: null,
|
||||
response: "",
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
triggerWorkflow: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +157,8 @@ export function newCronTrigger() {
|
||||
auth: null,
|
||||
response: "",
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
triggerWorkflow: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,6 +172,8 @@ export function newWorkflowTrigger() {
|
||||
auth: null,
|
||||
response: "",
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
triggerWorkflow: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,9 +258,18 @@ function normalizeTrigger(raw) {
|
||||
if (String(type).toLowerCase() === "http") type = "HTTP";
|
||||
const known =
|
||||
type === "HTTP"
|
||||
? new Set(["type", "method", "path", "auth", "response", "unauthorized"])
|
||||
? new Set([
|
||||
"type",
|
||||
"method",
|
||||
"path",
|
||||
"auth",
|
||||
"response",
|
||||
"unauthorized",
|
||||
"onConsecutiveFailures",
|
||||
"triggerWorkflow",
|
||||
])
|
||||
: type === "cron"
|
||||
? new Set(["type", "schedule"])
|
||||
? new Set(["type", "schedule", "onConsecutiveFailures", "triggerWorkflow"])
|
||||
: new Set(["type"]);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
@@ -267,6 +282,12 @@ function normalizeTrigger(raw) {
|
||||
method: typeof raw.method === "string" && raw.method ? raw.method : "POST",
|
||||
path: typeof raw.path === "string" ? raw.path : "",
|
||||
schedule: typeof raw.schedule === "string" ? raw.schedule : "",
|
||||
onConsecutiveFailures:
|
||||
raw.onConsecutiveFailures == null || raw.onConsecutiveFailures === ""
|
||||
? ""
|
||||
: String(raw.onConsecutiveFailures),
|
||||
triggerWorkflow:
|
||||
typeof raw.triggerWorkflow === "string" ? raw.triggerWorkflow : "",
|
||||
auth: raw.auth ?? null,
|
||||
response: typeof raw.response === "string" ? raw.response : "",
|
||||
unauthorized: raw.unauthorized ?? null,
|
||||
@@ -300,6 +321,18 @@ function dumpStep(step) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function dumpFailureTriggerFields(t, out) {
|
||||
if (t.onConsecutiveFailures !== "" && t.onConsecutiveFailures != null) {
|
||||
const threshold = Number(t.onConsecutiveFailures);
|
||||
if (Number.isFinite(threshold) && threshold > 0) {
|
||||
out.onConsecutiveFailures = Math.floor(threshold);
|
||||
}
|
||||
}
|
||||
if (typeof t.triggerWorkflow === "string" && t.triggerWorkflow.trim()) {
|
||||
out.triggerWorkflow = t.triggerWorkflow.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function dumpTrigger(t) {
|
||||
const type = String(t?.type ?? "").toLowerCase() === "http" ? "HTTP" : t?.type;
|
||||
if (type === "HTTP") {
|
||||
@@ -312,12 +345,14 @@ function dumpTrigger(t) {
|
||||
if (t.auth != null && t.auth !== false && t.auth !== "") out.auth = t.auth;
|
||||
if (t.response) out.response = t.response;
|
||||
if (t.unauthorized != null && t.unauthorized !== "") out.unauthorized = t.unauthorized;
|
||||
dumpFailureTriggerFields(t, out);
|
||||
Object.assign(out, t.extra ?? {});
|
||||
return out;
|
||||
}
|
||||
if (type === "cron") {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = { type: "cron", schedule: t.schedule || "" };
|
||||
dumpFailureTriggerFields(t, out);
|
||||
Object.assign(out, t.extra ?? {});
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user