Merge branch 'cursor/trigger-failure-workflow-3038' into cursor/ftp-sftp-script-3038

This commit is contained in:
2026-08-19 15:26:20 +07:00
17 changed files with 374 additions and 76 deletions
+2
View File
@@ -12,6 +12,7 @@ import { WorkflowsPage } from "./pages/WorkflowsPage.jsx";
import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx";
import { EventsPage } from "./pages/EventsPage.jsx";
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
import { FailuresPage } from "./pages/FailuresPage.jsx";
import { KvPage } from "./pages/KvPage.jsx";
import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
@@ -61,6 +62,7 @@ export function App() {
<Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} />
<Route path="/events" element={<EventsPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/failures" element={<FailuresPage />} />
<Route path="/kv" element={<KvPage />} />
<Route path="/variables" element={<VariablesPage />} />
<Route path="/auth" element={<AuthProfilesPage />} />
+11
View File
@@ -266,6 +266,17 @@ export function useRuns(filters = {}) {
});
}
export function useConsecutiveFailures(limit) {
return useQuery({
queryKey: ["consecutive-failures", limit ?? "all"],
queryFn: async () => {
const params = {};
if (limit) params.limit = limit;
return (await api.get("/consecutive-failures", { params })).data;
},
});
}
export function useRun(id) {
return useQuery({
queryKey: ["runs", id],
@@ -117,15 +117,15 @@ export function TriggerCard({
function triggerSummary(trigger, owner) {
const type = trigger?.type;
const failure =
trigger.onConsecutiveFailures && trigger.onFailureWorkflow
? ` · onFailure@${trigger.onFailureWorkflow}`
: "";
if (type === "HTTP") {
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}`;
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
}
if (type === "cron") {
const extra =
trigger.onConsecutiveFailures && trigger.triggerWorkflow
? ` · alert@${trigger.triggerWorkflow}`
: "";
return `${trigger.schedule || ""}${extra}`;
return `${trigger.schedule || ""}${failure}`;
}
if (type === "workflow") return "callable";
return "";
@@ -320,12 +320,12 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
/>
</label>
<label className="form-control">
<span className="label py-0 text-sm">Trigger workflow</span>
<span className="label py-0 text-sm">On failure, start</span>
<select
className="select select-sm"
value={trigger.triggerWorkflow ?? ""}
value={trigger.onFailureWorkflow ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, triggerWorkflow: e.target.value })}
onChange={(e) => onChange({ ...trigger, onFailureWorkflow: e.target.value })}
>
<option value="">None</option>
{alertDestinations.map((w) => (
@@ -333,11 +333,11 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
{w.name ?? w.file}
</option>
))}
{trigger.triggerWorkflow &&
{trigger.onFailureWorkflow &&
!alertDestinations.some(
(w) => (w.name ?? w.file) === trigger.triggerWorkflow,
(w) => (w.name ?? w.file) === trigger.onFailureWorkflow,
) ? (
<option value={trigger.triggerWorkflow}>{trigger.triggerWorkflow}</option>
<option value={trigger.onFailureWorkflow}>{trigger.onFailureWorkflow}</option>
) : null}
</select>
</label>
+12 -9
View File
@@ -143,7 +143,7 @@ export function newHttpTrigger() {
response: "",
unauthorized: null,
onConsecutiveFailures: "",
triggerWorkflow: "",
onFailureWorkflow: "",
};
}
@@ -158,7 +158,7 @@ export function newCronTrigger() {
response: "",
unauthorized: null,
onConsecutiveFailures: "",
triggerWorkflow: "",
onFailureWorkflow: "",
};
}
@@ -173,7 +173,7 @@ export function newWorkflowTrigger() {
response: "",
unauthorized: null,
onConsecutiveFailures: "",
triggerWorkflow: "",
onFailureWorkflow: "",
};
}
@@ -191,6 +191,10 @@ export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
export { HTTP_METHODS };
function readOnFailureWorkflow(raw) {
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
}
function normalizeStep(step) {
const uiId = nextUiId("step");
if (typeof step === "string") {
@@ -266,10 +270,10 @@ function normalizeTrigger(raw) {
"response",
"unauthorized",
"onConsecutiveFailures",
"triggerWorkflow",
"onFailureWorkflow",
])
: type === "cron"
? new Set(["type", "schedule", "onConsecutiveFailures", "triggerWorkflow"])
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"])
: new Set(["type"]);
/** @type {Record<string, unknown>} */
const extra = {};
@@ -286,8 +290,7 @@ function normalizeTrigger(raw) {
raw.onConsecutiveFailures == null || raw.onConsecutiveFailures === ""
? ""
: String(raw.onConsecutiveFailures),
triggerWorkflow:
typeof raw.triggerWorkflow === "string" ? raw.triggerWorkflow : "",
onFailureWorkflow: readOnFailureWorkflow(raw),
auth: raw.auth ?? null,
response: typeof raw.response === "string" ? raw.response : "",
unauthorized: raw.unauthorized ?? null,
@@ -328,8 +331,8 @@ function dumpFailureTriggerFields(t, out) {
out.onConsecutiveFailures = Math.floor(threshold);
}
}
if (typeof t.triggerWorkflow === "string" && t.triggerWorkflow.trim()) {
out.triggerWorkflow = t.triggerWorkflow.trim();
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
out.onFailureWorkflow = t.onFailureWorkflow.trim();
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function EventsPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">Events</h1>
<h1 className="text-xl font-semibold">{status === "failed" ? "Failed events" : "Events"}</h1>
<div className="flex flex-col sm:flex-row gap-2">
<input
className="input input-sm w-full sm:max-w-sm"
+63
View File
@@ -0,0 +1,63 @@
import { Link } from "react-router-dom";
import { useConsecutiveFailures } from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
export function FailuresPage() {
const { data, isLoading, error } = useConsecutiveFailures();
const items = data?.items ?? [];
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">Failures</h1>
<p className="opacity-70 text-sm">
Workflows that have failed 4 or more times in a row for the same trigger.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : error ? (
<p className="text-error">Failed to load consecutive failures</p>
) : items.length === 0 ? (
<p className="opacity-60 text-sm">None</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Streak</th>
<th>Workflow</th>
<th>Trigger</th>
<th>Last error</th>
<th>Last failed</th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr
key={`${s.workflow}\0${s.trigger_type}\0${s.trigger_detail ?? ""}`}
className="hover"
>
<td>
<span className="badge badge-error badge-sm">{s.consecutiveFailures}</span>
</td>
<td>
<Link className="link" to={`/events/${s.lastRun.id}`}>
{s.workflow_name || s.workflow}
</Link>
</td>
<td className="text-xs">
{s.trigger_type}
{s.trigger_detail ? ` · ${s.trigger_detail}` : ""}
</td>
<td className="max-w-md truncate text-xs" title={s.lastRun.error ?? ""}>
{s.lastRun.error || "—"}
</td>
<td className="whitespace-nowrap">{formatTime(s.lastRun.started_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+77 -43
View File
@@ -1,10 +1,5 @@
import { Link } from "react-router-dom";
import {
LuActivity,
LuCode,
LuGitBranch,
LuTriangleAlert,
} from "react-icons/lu";
import { LuCode, LuGitBranch, LuTriangleAlert } from "react-icons/lu";
import { useDashboard } from "../api/hooks.js";
import { formatTime, StatusBadge } from "../lib/format.jsx";
@@ -18,8 +13,10 @@ export function HomePage() {
return <p className="text-error">Failed to load dashboard</p>;
}
const failed = data.needsAttention?.failed ?? [];
const streaks = data.needsAttention?.consecutiveFailures ?? [];
const streakCount = data.needsAttention?.consecutiveFailureCount ?? streaks.length;
const broken = data.needsAttention?.brokenWorkflows ?? [];
const failedEvents = data.failedEvents ?? [];
return (
<div className="space-y-6">
@@ -39,52 +36,89 @@ export function HomePage() {
<div className="stat-title">Scripts</div>
<div className="stat-value text-2xl">{data.scriptCount}</div>
</div>
<div className="stat">
<div className="stat-figure text-warning">
<LuActivity className="size-7" />
</div>
<div className="stat-title">Running</div>
<div className="stat-value text-2xl">{data.running?.length ?? 0}</div>
</div>
<div className="stat">
<div className="stat-figure text-error">
<LuTriangleAlert className="size-7" />
</div>
<div className="stat-title">Attention</div>
<div className="stat-value text-2xl">
{failed.length + broken.length}
</div>
<div className="stat-value text-2xl">{streakCount + broken.length}</div>
</div>
</div>
<section>
<h2 className="text-lg font-semibold mb-2">Running</h2>
<RunList runs={data.running} empty="None" />
</section>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<section className="min-w-0">
<div className="mb-2 flex items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Needs attention</h2>
<Link className="link link-hover text-sm" to="/failures">
Show all
</Link>
</div>
{broken.length > 0 ? (
<ul className="mb-3 space-y-1">
{broken.map((w) => (
<li key={w.key}>
<Link
className="link link-error"
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
>
{w.key}: {w.loadError}
</Link>
</li>
))}
</ul>
) : null}
<StreakList streaks={streaks} empty={broken.length ? "" : "None"} />
</section>
<section>
<h2 className="text-lg font-semibold mb-2">Needs attention</h2>
{broken.length > 0 ? (
<ul className="mb-3 space-y-1">
{broken.map((w) => (
<li key={w.key}>
<Link
className="link link-error"
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
>
{w.key}: {w.loadError}
<section className="min-w-0">
<div className="mb-2 flex items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Failed events</h2>
<Link className="link link-hover text-sm" to="/events?status=failed">
View all
</Link>
</div>
<RunList runs={failedEvents} empty="None" />
</section>
<section className="min-w-0">
<h2 className="text-lg font-semibold mb-2">Recent</h2>
<RunList runs={data.recent} empty="None" />
</section>
</div>
</div>
);
}
function StreakList({ streaks, empty }) {
if (!streaks?.length) {
return empty ? <p className="opacity-60 text-sm">{empty}</p> : null;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Streak</th>
<th>Workflow</th>
<th>Last failed</th>
</tr>
</thead>
<tbody>
{streaks.map((s) => (
<tr key={`${s.workflow}\0${s.trigger_type}\0${s.trigger_detail ?? ""}`} className="hover">
<td>
<span className="badge badge-error badge-sm">{s.consecutiveFailures}</span>
</td>
<td>
<Link className="link" to={`/events/${s.lastRun.id}`}>
{s.workflow_name || s.workflow}
</Link>
</li>
))}
</ul>
) : null}
<RunList runs={failed} empty={broken.length ? "" : "None"} />
</section>
<section>
<h2 className="text-lg font-semibold mb-2">Recent</h2>
<RunList runs={data.recent} empty="None" />
</section>
</td>
<td className="whitespace-nowrap">{formatTime(s.lastRun.started_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}