From babb18a6d6519f1fcbc771112648467bf01e09bf Mon Sep 17 00:00:00 2001 From: Thosine_01 Date: Thu, 22 Oct 2026 01:13:46 +0100 Subject: [PATCH] feat(frontend): implement optimistic state rollback on network failure in Webhook Logs Table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Optimistically mark selected rows as "Retrying…" when a bulk retry is sent - On network failure or non-2xx response, restore the exact prior row state and selection, highlight affected rows, and show an inline alert with "Try again" / "Dismiss" actions - Roll back individual rows the server skipped; keep queued rows locked - Announce progress and failures via an aria-live status region - Add tests covering pending, network/server failure, partial skip and retry Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/WebhookLogs.test.tsx | 155 ++++++++++++++- frontend/src/components/WebhookLogs.tsx | 190 +++++++++++++++++-- 2 files changed, 329 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/WebhookLogs.test.tsx b/frontend/src/components/WebhookLogs.test.tsx index 0dff031c..9bd018de 100644 --- a/frontend/src/components/WebhookLogs.test.tsx +++ b/frontend/src/components/WebhookLogs.test.tsx @@ -7,10 +7,10 @@ import WebhookLogs from "./WebhookLogs"; vi.mock("react-hot-toast", () => ({ __esModule: true, - default: { + default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), - }, + }), })); vi.mock("@/components/ui/Button", () => ({ @@ -168,3 +168,154 @@ describe("WebhookLogs", () => { expect(preventDefaultSpy).toHaveBeenCalled(); }); }); + +describe("WebhookLogs optimistic retry rollback", () => { + const FAILED_A = { + id: "fail-a", + payment_id: "p1", + status_code: 500, + event: "payment.failed", + url: "https://example.com/wh", + request_payload: {}, + request_headers: null, + response_body: "error", + timestamp: "2026-04-25T10:00:00.000Z", + }; + const FAILED_B = { ...FAILED_A, id: "fail-b", event: "payment.expired", status_code: 404 }; + + const fetchMock = () => globalThis.fetch as unknown as ReturnType; + + async function renderWithFailedLogs() { + fetchMock().mockResolvedValueOnce({ + ok: true, + json: async () => ({ logs: [FAILED_A, FAILED_B] }), + }); + render(); + await waitFor(() => expect(screen.getByText("payment.failed")).toBeInTheDocument()); + } + + function rowFor(event: string) { + return screen.getByRole("button", { name: new RegExp(event.replace(".", "\.")) }); + } + + beforeEach(() => { + vi.resetAllMocks(); + globalThis.fetch = vi.fn(); + }); + + it("shows the optimistic pending state while the retry request is in flight", async () => { + await renderWithFailedLogs(); + + let resolveRetry: (value: unknown) => void = () => {}; + fetchMock().mockReturnValueOnce(new Promise((resolve) => (resolveRetry = resolve))); + + fireEvent.click(screen.getByLabelText("Select webhook log fail-a")); + fireEvent.click(screen.getByRole("button", { name: "Retry Selected" })); + + expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "pending"); + expect(rowFor("payment.failed")).toHaveAttribute("aria-busy", "true"); + expect(screen.getByText("Retrying…")).toBeInTheDocument(); + expect(screen.getByLabelText("Select webhook log fail-a")).not.toBeChecked(); + expect(screen.getByLabelText("Select webhook log fail-a")).toBeDisabled(); + + resolveRetry({ + ok: true, + json: async () => ({ queued_count: 1, skipped_count: 0, queued_log_ids: ["fail-a"] }), + }); + + await waitFor(() => + expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "queued"), + ); + expect(screen.getByText("Retry queued")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("rolls back row state and restores the selection on network failure", async () => { + await renderWithFailedLogs(); + fetchMock().mockRejectedValueOnce(new TypeError("Failed to fetch")); + + fireEvent.click(screen.getByLabelText("Select webhook log fail-a")); + fireEvent.click(screen.getByLabelText("Select webhook log fail-b")); + fireEvent.click(screen.getByRole("button", { name: "Retry Selected" })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(/network error/i); + expect(alert).toHaveTextContent(/selection has been restored/i); + + expect(screen.queryByText("Retrying…")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Select webhook log fail-a")).toBeChecked(); + expect(screen.getByLabelText("Select webhook log fail-b")).toBeChecked(); + expect(screen.getByLabelText("Select webhook log fail-a")).toBeEnabled(); + expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "rolled-back"); + expect(screen.getAllByText("Retry not queued")).toHaveLength(2); + expect(screen.getByRole("status")).toHaveTextContent(/retry failed/i); + }); + + it("rolls back on a non-2xx response using the server error message", async () => { + await renderWithFailedLogs(); + fetchMock().mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: "Rate limit exceeded" }), + }); + + fireEvent.click(screen.getByLabelText("Select webhook log fail-a")); + fireEvent.click(screen.getByRole("button", { name: "Retry Selected" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Rate limit exceeded"); + expect(screen.getByLabelText("Select webhook log fail-a")).toBeChecked(); + expect(screen.getByLabelText("Select webhook log fail-b")).not.toBeChecked(); + }); + + it("rolls back only the rows the server skipped", async () => { + await renderWithFailedLogs(); + fetchMock().mockResolvedValueOnce({ + ok: true, + json: async () => ({ queued_count: 1, skipped_count: 1, queued_log_ids: ["fail-a"] }), + }); + + fireEvent.click(screen.getByLabelText("Select all failed webhook logs")); + fireEvent.click(screen.getByRole("button", { name: "Retry Selected" })); + + await waitFor(() => + expect(rowFor("payment.failed")).toHaveAttribute("data-retry-state", "queued"), + ); + expect(rowFor("payment.expired")).toHaveAttribute("data-retry-state", "rolled-back"); + expect(screen.getByLabelText("Select webhook log fail-b")).toBeEnabled(); + expect(screen.getByLabelText("Select webhook log fail-a")).toBeDisabled(); + }); + + it("'Try again' re-sends the rolled-back logs and clears the alert on success", async () => { + await renderWithFailedLogs(); + fetchMock().mockRejectedValueOnce(new TypeError("Failed to fetch")); + + fireEvent.click(screen.getByLabelText("Select webhook log fail-b")); + fireEvent.click(screen.getByRole("button", { name: "Retry Selected" })); + await screen.findByRole("alert"); + + fetchMock().mockResolvedValueOnce({ + ok: true, + json: async () => ({ queued_count: 1, skipped_count: 0, queued_log_ids: ["fail-b"] }), + }); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + await waitFor(() => + expect(rowFor("payment.expired")).toHaveAttribute("data-retry-state", "queued"), + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + const lastCall = fetchMock().mock.calls.at(-1)!; + expect(JSON.parse(lastCall[1].body)).toEqual({ log_ids: ["fail-b"] }); + }); + + it("the rollback alert can be dismissed", async () => { + await renderWithFailedLogs(); + fetchMock().mockRejectedValueOnce(new TypeError("Failed to fetch")); + + fireEvent.click(screen.getByLabelText("Select webhook log fail-a")); + fireEvent.click(screen.getByRole("button", { name: "Retry Selected" })); + await screen.findByRole("alert"); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss retry error" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WebhookLogs.tsx b/frontend/src/components/WebhookLogs.tsx index 2a592474..f5a9385d 100644 --- a/frontend/src/components/WebhookLogs.tsx +++ b/frontend/src/components/WebhookLogs.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import WebhookLogSkeleton from "./WebhookLogSkeleton"; import toast from "react-hot-toast"; import { Button } from "@/components/ui/Button"; @@ -30,6 +30,31 @@ function isRetryable(log: WebhookLog) { return log.status_code >= 400; } +/** + * Client-side retry state for a single log row. + * - "pending": optimistically shown while the bulk retry request is in flight. + * - "queued": confirmed by the server; the row stays locked until the next refresh. + */ +export type RetryState = "pending" | "queued"; + +/** How long rolled-back rows stay highlighted after a failed retry. */ +export const ROLLBACK_HIGHLIGHT_MS = 4000; + +interface RollbackNotice { + message: string; + logIds: string[]; +} + +function describeRetryFailure(err: unknown) { + // fetch() rejects with a TypeError when the network is unreachable. + if (err instanceof TypeError) { + return "Network error: the retry request could not reach the server."; + } + return err instanceof Error && err.message + ? err.message + : "Failed to queue bulk webhook retry"; +} + export default function WebhookLogs() { useHydrateMerchantStore(); @@ -42,6 +67,26 @@ export default function WebhookLogs() { const [loading, setLoading] = useState(true); const [retrying, setRetrying] = useState(false); const [error, setError] = useState(null); + const [retryStates, setRetryStates] = useState>({}); + const [rollbackNotice, setRollbackNotice] = useState(null); + const [highlightedLogIds, setHighlightedLogIds] = useState([]); + const [liveMessage, setLiveMessage] = useState(""); + const highlightTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (highlightTimer.current) clearTimeout(highlightTimer.current); + }; + }, []); + + const flashRolledBackRows = useCallback((logIds: string[]) => { + if (highlightTimer.current) clearTimeout(highlightTimer.current); + setHighlightedLogIds(logIds); + highlightTimer.current = setTimeout(() => { + setHighlightedLogIds([]); + highlightTimer.current = null; + }, ROLLBACK_HIGHLIGHT_MS); + }, []); useEffect(() => { if (!hydrated) { @@ -78,6 +123,7 @@ export default function WebhookLogs() { const data = await response.json(); const entries = data.logs ?? data.entries ?? []; setLogs(entries); + setRetryStates({}); setSelectedLogIds((current) => current.filter((logId) => entries.some((entry: WebhookLog) => entry.id === logId && isRetryable(entry)), @@ -97,8 +143,10 @@ export default function WebhookLogs() { }, [apiKey, hydrated]); const retryableLogs = logs.filter(isRetryable); + // Rows with an in-flight or queued retry cannot be selected again. + const selectableLogs = retryableLogs.filter((log) => !retryStates[log.id]); const allRetryableSelected = - retryableLogs.length > 0 && retryableLogs.every((log) => selectedLogIds.includes(log.id)); + selectableLogs.length > 0 && selectableLogs.every((log) => selectedLogIds.includes(log.id)); const selectedRetryableCount = selectedLogIds.length; const toggleLogSelection = (logId: string) => { @@ -115,15 +163,41 @@ export default function WebhookLogs() { return; } - setSelectedLogIds(retryableLogs.map((log) => log.id)); + setSelectedLogIds(selectableLogs.map((log) => log.id)); }; - const handleBulkRetry = async () => { - if (!apiKey || selectedLogIds.length === 0) { + /** + * Queues retries for the given logs with an optimistic UI update. + * + * Rows flip to "Retrying…" immediately and the selection is cleared. If the + * request fails (network error or non-2xx response) the previous retry + * states and selection are restored exactly, the affected rows are + * highlighted, and an inline alert offers to try again. On success, rows the + * server skipped are rolled back individually while queued rows stay locked. + */ + const handleBulkRetry = async (logIds: string[] = selectedLogIds) => { + if (!apiKey || logIds.length === 0 || retrying) { return; } + const previousRetryStates = retryStates; + const previousSelection = selectedLogIds; + setRetrying(true); + setRollbackNotice(null); + setRetryStates((current) => { + const next = { ...current }; + logIds.forEach((id) => { + next[id] = "pending"; + }); + return next; + }); + setSelectedLogIds((current) => current.filter((id) => !logIds.includes(id))); + setLiveMessage( + logIds.length === 1 + ? "Retrying 1 webhook delivery." + : `Retrying ${logIds.length} webhook deliveries.`, + ); try { const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; @@ -133,7 +207,7 @@ export default function WebhookLogs() { "Content-Type": "application/json", "x-api-key": apiKey, }, - body: JSON.stringify({ log_ids: selectedLogIds }), + body: JSON.stringify({ log_ids: logIds }), }); const data = await response.json().catch(() => ({})); @@ -143,6 +217,23 @@ export default function WebhookLogs() { const queuedCount = Number(data.queued_count) || 0; const skippedCount = Number(data.skipped_count) || 0; + const queuedIds: string[] = Array.isArray(data.queued_log_ids) + ? data.queued_log_ids + : skippedCount === 0 + ? logIds + : []; + + setRetryStates((current) => { + const next = { ...current }; + logIds.forEach((id) => { + if (queuedIds.includes(id)) { + next[id] = "queued"; + } else { + delete next[id]; + } + }); + return next; + }); if (queuedCount > 0) { toast.success( @@ -158,12 +249,21 @@ export default function WebhookLogs() { ? "1 selected log was skipped." : `${skippedCount} selected logs were skipped.`, ); + flashRolledBackRows(logIds.filter((id) => !queuedIds.includes(id))); } - setSelectedLogIds([]); + setLiveMessage( + `${queuedCount} queued${skippedCount > 0 ? `, ${skippedCount} skipped` : ""}.`, + ); } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Failed to queue bulk webhook retry"; + const message = describeRetryFailure(err); + + // Roll back to the exact pre-request snapshot. + setRetryStates(previousRetryStates); + setSelectedLogIds(previousSelection); + setRollbackNotice({ message, logIds }); + flashRolledBackRows(logIds); + setLiveMessage(`Retry failed. ${message} Your selection has been restored.`); toast.error(message); } finally { setRetrying(false); @@ -213,14 +313,51 @@ export default function WebhookLogs() { +

+ {liveMessage} +

+ + {rollbackNotice && ( +
+
+

Retry not queued, changes rolled back

+

+ {rollbackNotice.message} Your selection has been restored. +

+
+
+ + +
+
+ )} +
@@ -231,7 +368,7 @@ export default function WebhookLogs() { type="checkbox" className="h-4 w-4 rounded border-white/20 bg-transparent text-mint focus:ring-mint" checked={allRetryableSelected} - disabled={retryableLogs.length === 0} + disabled={selectableLogs.length === 0 || retrying} onChange={toggleSelectAll} aria-label="Select all failed webhook logs" /> @@ -256,6 +393,8 @@ export default function WebhookLogs() { {logs.map((log) => { const retryable = isRetryable(log); const selected = selectedLogIds.includes(log.id); + const retryState = retryStates[log.id]; + const rolledBack = highlightedLogIds.includes(log.id); return ( setViewingLog(log)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -277,7 +420,7 @@ export default function WebhookLogs() { type="checkbox" className="h-4 w-4 rounded border-white/20 bg-transparent text-mint focus:ring-mint disabled:cursor-not-allowed disabled:opacity-40" checked={selected} - disabled={!retryable} + disabled={!retryable || !!retryState || retrying} onChange={() => toggleLogSelection(log.id)} aria-label={`Select webhook log ${log.id}`} /> @@ -290,6 +433,25 @@ export default function WebhookLogs() {
{log.event ?? "—"} + {retryState === "pending" && ( + + + )} + {retryState === "queued" && ( + + Retry queued + + )} + {!retryState && rolledBack && ( + + Retry not queued + + )} {!retryable && ( Successful deliveries do not need retries. )}