toggleOpen(x.id)}
+ /* Hovering a row is most of a second's head start on opening it, which is
+ enough that its retry chain is already there when the drawer renders.
+ Only for rows that have one — most exchanges do not. */
+ onMouseEnter={() => {
+ if (hasRetryChain(x)) void queryClient.prefetchQuery(retryTreeQuery(x.id));
+ }}
className="cursor-pointer border-b border-ink-50 transition-colors last:border-0 hover:bg-ink-50/60"
>
e.stopPropagation()}>
@@ -391,7 +454,7 @@ export function ExchangesPage() {
type="checkbox"
aria-label={`Select ${x.id}`}
className="size-3.5 cursor-pointer accent-crimson-600"
- checked={selected.has(x.id)}
+ checked={isSelected(x.id)}
onChange={() => toggleSelected(x.id)}
/>
@@ -514,29 +577,65 @@ export function ExchangesPage() {
)}
{/* — bulk action bar — */}
- {selected.size > 0 && (
-
-
- {selected.size} selected
-
-
- setSelected(new Set())}>
- Clear
-
- setBulkConfirm(true)}>
-
- Retry selected…
-
-
+ {selectedCount > 0 && (
+
+
+
+
+ {totalIsCapped && allMatching ? `${COUNT_CAP.toLocaleString()}+` : selectedCount.toLocaleString()}
+ {" "}
+ selected
+ {allMatching && — everything this filter matches }
+ {allMatching && excluded.size > 0 && (
+ , {excluded.size} unticked
+ )}
+
+
+
+ Clear
+
+ setBulkConfirm(true)}>
+
+ Retry selected…
+
+
+
+
+ {/* The whole page is ticked but there is more behind it — the one moment where
+ "select all matching" is what someone actually wants, so it is offered there
+ rather than living permanently in the toolbar. */}
+ {!allMatching && allOnPageSelected && total > rows.length && (
+
+ Only the {rows.length} rows on this page.{" "}
+ {
+ setAllMatching(true);
+ setSelected(new Set());
+ setExcluded(new Set());
+ }}
+ className="font-medium text-crimson-700 hover:underline"
+ >
+ Select all {totalIsCapped ? `${COUNT_CAP.toLocaleString()}+` : total.toLocaleString()} matching
+ this filter
+
+
+ )}
)}
{bulkConfirm && (
bulkRetry.mutate(reset)}
- onClose={() => setBulkConfirm(false)}
+ onResetChange={setBulkReset}
+ onClose={() => {
+ setBulkConfirm(false);
+ // The dialog starts unticked each time it opens, so the mirrored copy has to as well.
+ setBulkReset(false);
+ }}
/>
)}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/RetryChain.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/RetryChain.tsx
new file mode 100644
index 00000000..a784e617
--- /dev/null
+++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/RetryChain.tsx
@@ -0,0 +1,189 @@
+import { Link, useNavigate } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import { api, type RetryTree, type RetryTreeNode } from "../../api";
+import { keys } from "../../api/queryKeys";
+import { Badge } from "../../components/ui/basics";
+import { PromotedProps, namesSomething } from "../../components/config/shared";
+import { timeAgo, timeUntil } from "../../lib/dates";
+import { StatusBadge } from "./shared";
+
+/**
+ * The whole chain is worth asking for only when the row says there is something in it. Both
+ * facts come back with every exchange row, so an exchange that was never retried and is not
+ * itself a retry — most of them — costs no request at all.
+ */
+export const hasRetryChain = (x: { retryFor: string | null; hasRetry: boolean }) =>
+ x.retryFor !== null || x.hasRetry;
+
+export const retryTreeQuery = (id: string) => ({
+ queryKey: keys.exchanges.retryTree(id),
+ queryFn: () => api.getRetryTree(id),
+ /**
+ * A chain is settled history above the exchange asked about and only ever grows below it, so
+ * a held copy cannot be wrong about what it shows — at worst it is missing an attempt someone
+ * has just started, which invalidating on retry covers.
+ */
+ staleTime: 60_000,
+});
+
+/** The end of the chain below `fromId` — the attempt a retry would actually run. */
+export function newestAttempt(tree: RetryTree, fromId: string): RetryTreeNode | null {
+ let current = tree.attempts.find((a) => a.id === fromId) ?? null;
+ if (!current) return null;
+
+ for (;;) {
+ // Newest first, so a chain that forked before one-retry-per-exchange was enforced resolves
+ // the same way the backend resolves it.
+ const children = tree.attempts
+ .filter((a) => a.retryFor === current!.id)
+ .sort((a, b) => b.startedOn.localeCompare(a.startedOn));
+ if (children.length === 0) return current;
+ current = children[0];
+ }
+}
+
+const childrenOf = (tree: RetryTree, id: string | null) =>
+ tree.attempts
+ .filter((a) => a.retryFor === id)
+ .sort((a, b) => a.startedOn.localeCompare(b.startedOn));
+
+function Attempt({
+ node,
+ tree,
+ currentId,
+ depth,
+}: {
+ node: RetryTreeNode;
+ tree: RetryTree;
+ currentId: string;
+ depth: number;
+}) {
+ const isCurrent = node.id === currentId;
+ const children = childrenOf(tree, node.id);
+ const navigate = useNavigate();
+
+ return (
+ <>
+ navigate(`/exchanges?ids=${encodeURIComponent(node.id)}`)}
+ className={`flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md px-2 py-1.5 ${
+ isCurrent ? "bg-ink-100/70" : "cursor-pointer hover:bg-ink-50"
+ }`}
+ style={{ marginLeft: depth * 14 }}
+ >
+ {/* The attempt number carries the link, rather than the identity on the right: promoted
+ properties can open a panel of their own, and a button inside a link is neither. */}
+ {isCurrent ? (
+
+ Attempt {depth + 1}
+
+ ) : (
+
+ Attempt {depth + 1}
+
+ )}
+
+
+ {node.retryFor === null ? "Original" : node.manualRetry ? "By hand" : "Auto"}
+
+ {/* Without this, a fork reads as a mistake: two branches put two different exchanges at
+ the same depth, so the same attempt number appears twice and looks like one exchange
+ retried twice over. Only reachable in exchanges retried before the rule existed. */}
+ {children.length > 1 && (
+
+ {children.length} retries from here
+
+ )}
+ {node.scheduledRetryOn && (
+
+ Auto-retry {timeUntil(node.scheduledRetryOn)}
+
+ )}
+
+ {timeAgo(node.startedOn)}
+
+
+ {/* Named the way the exchange list names a row — the promoted properties are what
+ someone recognises an exchange by, and the id falls back in when there are none. */}
+
+ {namesSomething(node.promotedProperties) ? (
+
+ ) : (
+ {node.id}
+ )}
+ {isCurrent && (
+
+ You are here
+
+ )}
+
+
+ {children.map((child) => (
+
+ ))}
+ >
+ );
+}
+
+const ordinal = (n: number) => {
+ const names = ["first", "second", "third", "fourth", "fifth"];
+ return names[n - 1] ?? `${n}th`;
+};
+
+/**
+ * Every attempt made at one piece of work, in order, with the exchange being looked at marked.
+ * An exchange is retried at most once, so this reads as a chain; exchanges retried before that
+ * rule was enforced can fork, and those show as branches rather than being hidden.
+ */
+export function RetryChain({ id }: { id: string }) {
+ const { data: tree, isLoading } = useQuery(retryTreeQuery(id));
+
+ // A line rather than a spinner block: this sits inside an already-rendered drawer, and it is
+ // usually filled in before anyone looks at it — the row is prefetched on hover.
+ if (isLoading)
+ return Loading the retry chain…
;
+ if (!tree || tree.attempts.length < 2) return null;
+
+ const root = tree.attempts.find((a) => a.id === tree.rootId) ?? tree.attempts[0];
+
+ return (
+
+
+ Retry chain · {tree.attempts.length} attempts
+
+
+ {tree.truncated && (
+
+ Only the attempts nearest this one are shown — the chain is longer than this view walks.
+
+ )}
+
+ );
+}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx
index 57559fb6..bb4313f4 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx
@@ -1,7 +1,8 @@
import { useState } from "react";
-import { Check, Copy } from "lucide-react";
-import type { ExchangeRow, ExchangeStatus } from "../../api";
+import { ArrowRight, Check, Copy } from "lucide-react";
+import type { BulkRetryPlan, ExchangeRow, ExchangeStatus } from "../../api";
import { Badge, Button } from "../../components/ui/basics";
+import { PromotedProps, namesSomething } from "../../components/config/shared";
import { Checkbox } from "../../components/ui/forms";
import { Dialog } from "../../components/ui/overlays";
@@ -112,45 +113,171 @@ export function XchangeId({ id, className = "" }: { id: string; className?: stri
}
/**
- * Shared confirm for single and bulk retries — carries the "reset adapter
- * properties" choice that decides whether the retry re-resolves config.
+ * How the plan lists name an exchange: the promoted properties the exchange list names it by,
+ * and enough of the id to tell two of them apart.
+ *
+ * The id stays because a substitution puts two exchanges side by side, and an information type
+ * whose promoted paths resolved to nothing gives both of them the same chips — "trackingNo= →
+ * trackingNo=" says which fields exist and nothing about which exchanges these are.
+ */
+function ExchangeIdentity({
+ id,
+ properties,
+}: {
+ id: string;
+ properties: Record | null;
+}) {
+ // Properties that carry no values name nothing, so they are left out entirely rather than
+ // shown as a row of empty chips next to an identical row of empty chips.
+ if (!namesSomething(properties))
+ return (
+
+ {id}
+
+ );
+
+ return (
+
+
+
+ {id.slice(0, 8)}…
+
+
+ );
+}
+
+/**
+ * Shared confirm for single and bulk retries — carries the "reset adapter properties" choice
+ * that decides whether the retry re-resolves config.
+ *
+ * A bulk retry also passes the `plan` the server worked out for the same selection, because a
+ * selection is rarely just itself: exchanges already retried hand over to their newest attempt,
+ * ones that have since succeeded drop out, and two selections in one chain come to the same
+ * attempt. All of that is shown before anyone commits, since a retry cannot be taken back.
*/
export function RetryDialog({
count,
+ plan,
+ planLoading = false,
busy,
onConfirm,
+ onResetChange,
onClose,
}: {
count: number;
+ /** Bulk retries only — a single retry has nothing to resolve. */
+ plan?: BulkRetryPlan | null;
+ planLoading?: boolean;
busy: boolean;
onConfirm: (reset: boolean) => void;
+ /**
+ * Bulk retries only: the plan depends on this choice — re-resolving properties is impossible
+ * for an exchange whose subscription is gone — so the caller has to be able to ask again.
+ */
+ onResetChange?: (reset: boolean) => void;
onClose: () => void;
}) {
const [reset, setReset] = useState(false);
+ const bulk = count !== 1;
+ const nothingToDo = plan != null && !plan.overLimit && plan.willRetry === 0;
+
return (
-
- The original input document{count === 1 ? "" : "s"} will run through the pipeline again as{" "}
- {count === 1 ? "a new exchange" : "new exchanges"}.
- {count > 1 && " Exchanges that already have a pending auto-retry are skipped."}
-
-
setReset(e.target.checked)}
- />
+ {plan?.overLimit ? (
+
+ {plan.selected.toLocaleString()} exchanges match this filter, which is more than the{" "}
+ {plan.limit.toLocaleString()} a single retry will carry out. Narrow the filter — by
+ status, partner or date — and retry the rest after.
+
+ ) : (
+ <>
+
+ {bulk && planLoading
+ ? "Working out what will run…"
+ : plan
+ ? plan.willRetry === 0
+ ? "Nothing here can be retried."
+ : `${plan.willRetry.toLocaleString()} ${
+ plan.willRetry === 1 ? "exchange" : "exchanges"
+ } will run again — the original input document goes back through the pipeline as a new exchange.`
+ : `The original input document${count === 1 ? "" : "s"} will run through the pipeline again as ${
+ count === 1 ? "a new exchange" : "new exchanges"
+ }.`}
+
+
+ {plan != null && plan.substituted.length > 0 && (
+
+
+ {plan.substituted.length.toLocaleString()} of
+ these {plan.substituted.length === 1 ? "has" : "have"} already been retried. An
+ exchange is only retried once, so{" "}
+ {plan.substituted.length === 1 ? "its newest attempt runs" : "their newest attempts run"}{" "}
+ instead.
+
+
+
+ Show which
+
+
+ {plan.substituted.map((s) => (
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {plan != null && plan.skipped.length > 0 && (
+
+
+ {plan.skipped.length.toLocaleString()} {" "}
+ will be skipped.
+
+
+
+ Show why
+
+
+ {plan.skipped.map((s) => (
+
+
+ — {s.reason}
+
+ ))}
+
+
+
+ )}
+
+ {
+ setReset(e.target.checked);
+ onResetChange?.(e.target.checked);
+ }}
+ />
+ >
+ )}
+
- Cancel
-
- onConfirm(reset)}>
- Retry
+ {plan?.overLimit || nothingToDo ? "Close" : "Cancel"}
+ {!plan?.overLimit && !nothingToDo && (
+ onConfirm(reset)}>
+ Retry
+
+ )}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx
index 935a0cca..2e369c64 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx
@@ -157,7 +157,9 @@ export function ScheduledRetriesPage() {
// Same identity rule as the Exchanges list: what it carries first,
// the id only as a link out.
header: "Properties",
- cell: (r) => ,
+ // With a fallback id, so a row whose promoted paths resolved to nothing keeps an
+ // identity of its own rather than falling back to a bare dash.
+ cell: (r) => ,
},
{
header: "Information type",