feat(failures): implement consecutive failure tracking and dashboard integration

- Added a new function to list consecutive failure streaks for workflows, allowing tracking of workflows that have failed multiple times in a row.
- Integrated the consecutive failure data into the dashboard, displaying streaks and counts for better visibility of workflow health.
- Created a dedicated FailuresPage to present detailed information about consecutive failures, enhancing user experience and monitoring capabilities.
- Updated API endpoints and hooks to support the new failure tracking features, ensuring seamless data retrieval and display.

This enhancement improves the ability to monitor and respond to workflow failures, contributing to overall system reliability.
This commit is contained in:
2026-08-18 11:14:46 +07:00
parent c668947c90
commit 04c0d4b801
9 changed files with 262 additions and 47 deletions
+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>
);
}