Merge branch 'main' into cursor/bullmq-queue-phase-a-3038
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,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">
|
||||
@@ -51,9 +53,7 @@ export function HomePage() {
|
||||
<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>
|
||||
|
||||
@@ -62,29 +62,80 @@ export function HomePage() {
|
||||
<RunList runs={data.running} empty="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}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<RunList runs={failed} empty={broken.length ? "" : "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">Recent</h2>
|
||||
<RunList runs={data.recent} empty="None" />
|
||||
</section>
|
||||
<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>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">{formatTime(s.lastRun.started_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,11 +180,11 @@ export function SecretsPage() {
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Values are encrypted at rest and never shown again after save.
|
||||
Values shorter than 8 characters are not redacted from logs.
|
||||
</p>
|
||||
{upsert.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||
|
||||
Reference in New Issue
Block a user