M4: implement n8n automation
Outbox dispatcher (background thread, FOR UPDATE SKIP LOCKED claim, exponential backoff, no transaction held during HTTP I/O). n8n callback endpoint with shared-secret auth and idempotency by event ID. Automation nav + UI with manual retry. 49 backend tests passing, ruff clean. Fixed a crash-on-redelivery bug in seeded outbox payloads and made the dispatcher defensive against malformed payloads. Verified the full live round trip against a real n8n instance: return -> outbox -> dispatcher -> n8n workflow -> callback -> succeeded, including the S5 failed-retry demo scenario.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { AutomationRun } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export function Automation() {
|
||||
const { user } = useAuth();
|
||||
const [runs, setRuns] = useState<AutomationRun[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [retryError, setRetryError] = useState<string | null>(null);
|
||||
const [retrying, setRetrying] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
api
|
||||
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
|
||||
.then(setRuns)
|
||||
.catch(() =>
|
||||
setError(
|
||||
user?.role === "operations_manager"
|
||||
? "Automation runs are unavailable right now."
|
||||
: "Automation is only visible to Operations Managers.",
|
||||
),
|
||||
);
|
||||
}, [status, user]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleRetry(eventId: string) {
|
||||
setRetryError(null);
|
||||
setRetrying(eventId);
|
||||
try {
|
||||
await api.post(`/api/v1/workflows/${eventId}/retry`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setRetryError(err instanceof ApiError ? err.message : "Could not retry this delivery.");
|
||||
} finally {
|
||||
setRetrying(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== "operations_manager") {
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Automation</h1>
|
||||
<p>Automation delivery status is visible to Operations Managers only.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Automation</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter automation runs">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="delivering">Delivering</option>
|
||||
<option value="succeeded">Succeeded</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{retryError && <p className="error" role="alert">{retryError}</p>}
|
||||
{!error && !runs && <p>Loading automation runs…</p>}
|
||||
{runs && runs.length === 0 && <p>No automation runs match this filter.</p>}
|
||||
|
||||
{runs && runs.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Automation runs</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Event</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Booking</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Attempts</th>
|
||||
<th scope="col">Last error</th>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.event_id}>
|
||||
<td className="mono">{r.event_id.slice(0, 8)}</td>
|
||||
<td>{r.event_type}</td>
|
||||
<td>{r.aggregate_ref}</td>
|
||||
<td>
|
||||
<StatusBadge status={r.status} />
|
||||
</td>
|
||||
<td>{r.attempts}</td>
|
||||
<td>{r.last_error ?? "—"}</td>
|
||||
<td>
|
||||
<time dateTime={r.occurred_at}>
|
||||
{new Date(r.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
{r.status === "failed" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(r.event_id)}
|
||||
disabled={retrying === r.event_id}
|
||||
>
|
||||
{retrying === r.event_id ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user