From 6d5768b8265d6c617624fe6d4daea31b7b89df3b Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 10 Sep 2026 15:58:11 +0300 Subject: [PATCH 1/2] feat: ask for the newest attempt of each chain, and rank the stuck ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chain is one piece of work however many attempts it took, so a list of failures otherwise shows one problem as many rows. LatestOnly narrows it to where each chain got to — an anti-join on the indexed RetryFor, shared with bulk retry, which then has nothing left to substitute. The dashboard's summary adds the two things a count of failures cannot say: which chains keep failing however often they are retried, and whether retrying achieves anything here. Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/Dashboard/RetrySummary.cs | 169 ++++++++++++++++++ .../Resources/Xchanges/XchangeFilters.cs | 15 ++ .../Tests/RetryChainTests.cs | 89 +++++++++ 3 files changed, 273 insertions(+) create mode 100644 SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs diff --git a/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs b/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs new file mode 100644 index 00000000..62ba0cbd --- /dev/null +++ b/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Dashboard; + +/// +/// The two things a retry chain can say that a count of failures cannot: which pieces of work +/// have been retried over and over and are still failing, and whether retrying is achieving +/// anything at all. +/// +/// +/// Both in one handler because both belong to the same panel — a repeat offender means little +/// without knowing whether retries generally work here. Nine attempts against the same connection +/// error is a configuration problem someone has to fix; nine attempts where most retries do +/// eventually succeed is bad luck. +/// +[HandlerName("retrysummary")] +public class RetrySummary : IQueryHandler +{ + /// + /// How many failures the chain lengths are measured over, newest first. The walk below costs a + /// query per level for the whole set at once, so this bounds the work without narrowing the + /// answer in practice: a chain long enough to be listed is recent, because something has been + /// retrying it. + /// + private const int Candidates = 500; + + /// Enough to show a pattern; the full list is one link away. + private const int Listed = 5; + + /// Guards the walk against a cycle in the data, as elsewhere. + private const int MaxDepth = 100; + + private const int WindowDays = 7; + + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public RetrySummary(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle() + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Dashboard.View); + + var since = DateTime.UtcNow.AddDays(-WindowDays); + + // Did retrying help? Every attempt that was itself a retry, and how it ended. A retry with + // no result yet is counted in neither, so the two never add up to more than what finished. + var retries = await ( + from xchange in _dbContext.Set() + join result in _dbContext.Set() on xchange.Id equals result.Id + where xchange.RetryFor != null && xchange.StartedOn >= since + group result by result.Success && !result.ResponseBad into worked + select new { Worked = worked.Key, Count = worked.Count() }).AsNoTracking().ToListAsync(); + + var succeeded = retries.Where(r => r.Worked).Sum(r => r.Count); + var finished = retries.Sum(r => r.Count); + + // Chains that keep failing: a failure nothing has been retried from — so it is where that + // chain has got to — which is itself a retry, so the chain is at least two attempts long. + var candidates = await ( + from xchange in _dbContext.Set() + join result in _dbContext.Set() on xchange.Id equals result.Id + where xchange.RetryFor != null + && !result.Success + && !_dbContext.Set().Any(child => child.RetryFor == xchange.Id) + orderby xchange.StartedOn descending + select new + { + xchange.Id, + xchange.RetryFor, + xchange.SubscriptionId, + xchange.DocumentId, + xchange.StartedOn, + result.Exception + }).AsNoTracking().Take(Candidates).ToListAsync(); + + var attempts = await CountAttempts(candidates.ToDictionary(c => c.Id, c => c.RetryFor)); + + var worst = candidates + .OrderByDescending(c => attempts[c.Id]) + .ThenByDescending(c => c.StartedOn) + .Take(Listed) + .ToList(); + + // Names for the few that are actually listed, rather than for all five hundred. + var subscriptionIds = worst.Where(w => w.SubscriptionId != null).Select(w => w.SubscriptionId!.Value).ToList(); + var subscriptionNames = await _dbContext.Set().AsNoTracking() + .Where(s => subscriptionIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Name); + + var documentIds = worst.Select(w => w.DocumentId).Distinct().ToList(); + var documentNames = await _dbContext.Set().AsNoTracking() + .Where(d => documentIds.Contains(d.Id)) + .ToDictionaryAsync(d => d.Id, d => d.Name); + + return new + { + RetriesLast7Days = new { Finished = finished, Succeeded = succeeded }, + FailingChains = worst.Select(w => new + { + w.Id, + Attempts = attempts[w.Id], + w.SubscriptionId, + SubscriptionName = w.SubscriptionId != null && subscriptionNames.ContainsKey(w.SubscriptionId.Value) + ? subscriptionNames[w.SubscriptionId.Value] + : null, + InformationTypeCode = documentNames.GetValueOrDefault(w.DocumentId), + w.StartedOn, + w.Exception + }) + }; + } + + /// + /// How many attempts each chain has taken, counting the original as one. + /// + /// + /// A level of every chain per query rather than a walk per chain — five hundred chains four + /// attempts deep is four queries, not two thousand. XchangeResult.AttemptNumber holds + /// this already, but only for failures a retry policy's group matched, so a chain retried by + /// hand has none and it cannot be read from there. + /// + private async Task> CountAttempts(Dictionary parentOf) + { + // Every chain starts at two: the candidate is a retry, so something came before it. + var attempts = parentOf.Keys.ToDictionary(id => id, _ => 2); + var frontier = new Dictionary(parentOf); + + for (var depth = 0; depth < MaxDepth; depth++) + { + var ancestors = frontier.Values.Where(id => id != null).Distinct().ToList(); + if (ancestors.Count == 0) break; + + var next = await _dbContext.Set().AsNoTracking() + .Where(x => ancestors.Contains(x.Id)) + .Select(x => new { x.Id, x.RetryFor }) + .ToDictionaryAsync(x => x.Id, x => x.RetryFor); + + var moved = false; + foreach (var id in frontier.Keys.ToList()) + { + var at = frontier[id]; + if (at == null || !next.TryGetValue(at, out var parent) || parent == null) + { + frontier[id] = null; + continue; + } + + frontier[id] = parent; + attempts[id]++; + moved = true; + } + + if (!moved) break; + } + + return attempts; + } +} diff --git a/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs b/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs index 395e3639..c4e8fd3d 100644 --- a/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs +++ b/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs @@ -100,6 +100,21 @@ internal static IQueryable ApplySpecialFilters(this IQueryable f.Field == "LatestOnly").ToList(); + foreach (var latestFilter in latestFilters) + { + if (latestFilter.Value?.ToString()?.ToLower() is "true" or "1") + query = query.Where(i => !dbContext.Set().Any(r => r.RetryFor == i.Id)); + + condition.Filters.Remove(latestFilter); + } + var propertiesFilters = condition.Filters .Where(f => f.Field == "PromotedPropertiesRaw").ToList(); foreach (var propertyFilter in propertiesFilters) diff --git a/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs index ba9ddd23..a7020a0f 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs @@ -383,6 +383,54 @@ await Assert.ThrowsAsync(() => Assert.False(await db.Set().AnyAsync(x => x.RetryFor == xchange.Id)); } + // ─── What the dashboard reads ───────────────────────────────────────────── + + /// + /// The dashboard's retry panel: which chains have been retried repeatedly and are still + /// failing, and whether retrying is achieving anything. + /// + [Fact] + public async Task RetrySummary_ranks_the_chains_that_keep_failing_and_says_whether_retries_work() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + + // A chain of four failed attempts. + var (_, deep) = await FailedXchange(db, xs, "Summary Deep Doc"); + var second = await RetryOnce(db, xs, ctx, deep.Id); + var third = await RetryOnce(db, xs, ctx, second.Id); + var fourth = await RetryOnce(db, xs, ctx, third.Id); + + // And one that was retried once, where the retry worked. + var (_, healed) = await FailedXchange(db, xs, "Summary Healed Doc"); + await new Resources.Xchanges.Retry(db, ctx, xs).Handle(healed.Id, new XchangeRetry()); + await db.SaveChangesAsync(); + var healedRetry = await db.Set().FirstAsync(x => x.RetryFor == healed.Id); + db.Set().Add(new XchangeResult(healedRetry.Id, null, null)); + await db.SaveChangesAsync(); + + var summary = await new Resources.Dashboard.RetrySummary(db, ctx).Handle(); + var json = System.Text.Json.JsonSerializer.SerializeToElement(summary); + + // The still-failing chain is listed, at its true length. The one that came good is not: + // its newest attempt succeeded, so there is nothing left to act on. + var chains = json.GetProperty("FailingChains").EnumerateArray().ToList(); + var listed = chains.FirstOrDefault(c => c.GetProperty("Id").GetString() == fourth.Id); + Assert.Equal(4, listed.GetProperty("Attempts").GetInt32()); + Assert.DoesNotContain(chains, c => c.GetProperty("Id").GetString() == healedRetry.Id); + + // Superseded attempts are not listed either — only where each chain got to. + foreach (var stale in new[] { deep.Id, second.Id, third.Id }) + Assert.DoesNotContain(chains, c => c.GetProperty("Id").GetString() == stale); + + // And retries that worked are counted among the ones that finished. + var retries = json.GetProperty("RetriesLast7Days"); + Assert.True(retries.GetProperty("Succeeded").GetInt32() >= 1); + Assert.True(retries.GetProperty("Finished").GetInt32() >= retries.GetProperty("Succeeded").GetInt32()); + } + // ─── Reading the chain back ─────────────────────────────────────────────── [Fact] @@ -410,6 +458,47 @@ public async Task RetryTree_returns_the_whole_chain_whichever_attempt_is_asked_a } } + /// + /// A chain is one piece of work, however many attempts it took. Asking for the newest attempt + /// only is what turns a list of failures into a list of things still to deal with — and, used + /// with a bulk retry, means the selection is already the attempts that can be retried. + /// + [Fact] + public async Task Exchange_search_can_return_only_the_newest_attempt_of_each_chain() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + + var (sub, first) = await FailedXchange(db, xs, "Latest Only Doc"); + var second = await RetryOnce(db, xs, ctx, first.Id); + var third = await RetryOnce(db, xs, ctx, second.Id); + + // A second, unrelated failure on the same subscription that was never retried. + var alone = await xs.CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + db.Set().Add(new XchangeResult(alone.Id, null, null, exception: "boom")); + await db.SaveChangesAsync(); + + var search = new Resources.Xchanges.Search(db, xs, ctx); + + var all = (SearchyResponse)await search.Handle( + new SearchyRequest($"filter=SubscriptionId:1:{sub.Id}") { PageSize = 50 }); + Assert.Equal(4, all.Result.Count()); + + var latest = (SearchyResponse)await search.Handle( + new SearchyRequest($"filter=SubscriptionId:1:{sub.Id}&filter=LatestOnly:1:true") { PageSize = 50 }); + + // The chain collapses to its own end, and the standalone failure is its own end too. + var ids = latest.Result.Select(r => r.Id).ToList(); + Assert.Equal(2, ids.Count); + Assert.Contains(third.Id, ids); + Assert.Contains(alone.Id, ids); + Assert.Equal(2, latest.TotalCount); + Assert.All(latest.Result, r => Assert.False(r.HasRetry)); + } + [Fact] public async Task Exchange_search_reports_which_rows_have_been_retried() { From 9015d42ede4b561d207506dfc6a42509ab79a97a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 10 Sep 2026 15:58:11 +0300 Subject: [PATCH 2/2] feat: a dashboard panel for stuck chains, and a filter for live attempts The "Failures to act on" tile counts problems where "failed today" counts attempts, and reads its number from the search so it agrees with the list it opens. The panel beside Latest failures says which chains are not getting better, with the retry success rate as the context that makes a long chain either bad luck or something to fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientApp/e2e/dashboard.spec.ts | 24 ++++++ .../ClientApp/e2e/exchanges.spec.ts | 37 ++++++++ .../ClientApp/src/api/http/dashboard.ts | 24 +++++- .../ClientApp/src/api/http/exchanges.ts | 1 + SW.Bitween.Web/ClientApp/src/api/types.ts | 32 +++++++ .../src/pages/dashboard/DashboardPage.tsx | 84 ++++++++++++++++++- .../src/pages/exchanges/ExchangesPage.tsx | 26 +++++- 7 files changed, 224 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index 2cf8bfae..55696b85 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -16,4 +16,28 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page.getByText("Success rate (7 days)")).toBeVisible(); await expect(page.getByText("undefined")).toHaveCount(0); await expect(page.getByText("NaN")).toHaveCount(0); + + // "Failures to act on" counts problems rather than attempts, and has to agree with the list it + // opens — a tile whose number changes when you click it is worse than no tile. + // Anchored: the "Chains that keep failing" panel ends with an "All failures to act on" link, + // which an unanchored match picks up as well. + const tile = page.getByRole("link").filter({ hasText: /^Failures to act on/ }); + await expect(tile).toBeVisible(); + const count = (await tile.innerText()).match(/([\d,]+)/)?.[1]; + expect(count).toBeTruthy(); + + await tile.click(); + await expect(page).toHaveURL(/status=failed&latest=1/); + await expect(page.getByText(`of ${count}`)).toBeVisible({ timeout: 15000 }); + + // The panel that says which of those failures are not getting better, however often they are + // retried — the number on each row is how many attempts that one piece of work has taken. + await page.goBack(); + const chains = page.getByText("Chains that keep failing"); + await expect(chains).toBeVisible(); + const rows = page.getByRole("link").filter({ hasText: /^\d+ attempts$/ }); + if ((await rows.count()) > 0) { + await rows.first().click(); + await expect(page).toHaveURL(/\/exchanges\?ids=/); + } }); diff --git a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts index 0224089e..b5cd3c9d 100644 --- a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts @@ -170,3 +170,40 @@ test("select all matching covers the whole filter, and the confirm says what wil await dialog.locator("button", { hasText: /^(Cancel|Close)$/ }).click(); await expect(dialog).toHaveCount(0); }); + +/** + * A chain is one piece of work however many attempts it took, so the list needs to be able to + * show the newest attempt of each — otherwise a chain retried nine times fills nine rows, none + * of which is the current state of anything. + */ +test("the list can show only the newest attempt of each chain", async ({ page }) => { + await page.goto("exchanges?status=failed"); + await expect(page.getByRole("row").nth(1)).toBeVisible({ timeout: 15000 }); + await page.getByLabel("Refresh interval").selectOption("0"); + + const total = async () => + Number( + (await page.locator("text=/Showing .* of [\\d,]+/").first().innerText()) + .match(/of ([\d,]+)/)![1] + .replace(/,/g, ""), + ); + + const attempts = await total(); + const pill = page.getByRole("button", { name: "Latest attempt only" }); + await expect(pill).toHaveAttribute("aria-pressed", "false"); + + await pill.click(); + await expect(page).toHaveURL(/latest=1/); + await expect(pill).toHaveAttribute("aria-pressed", "true"); + + // Never more than the attempts, and fewer as soon as anything has been retried. + const problems = await total(); + expect(problems).toBeLessThanOrEqual(attempts); + + // And it survives a reload, since it lives in the URL like every other filter. + await page.reload(); + await expect(page.getByRole("button", { name: "Latest attempt only" })).toHaveAttribute( + "aria-pressed", + "true", + ); +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts index ffd01584..4bd77677 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -20,6 +20,18 @@ interface RawXchangeForDashboard { interface RawAlert { severity: "Info" | "Warning" | "Critical"; } +interface RawRetrySummary { + retriesLast7Days: { finished: number; succeeded: number }; + failingChains: { + id: string; + attempts: number; + subscriptionId: number | null; + subscriptionName: string | null; + informationTypeCode: string | null; + startedOn: string; + exception: string | null; + }[]; +} const isBad = (raw: Pick) => raw.status === false || (raw.status === true && raw.responseBad === true); @@ -40,11 +52,18 @@ export const dashboardMethods = { // request, and exact rather than approximated. Bounded to a generous page // size for what's a modest-scale ops tool; a very high-volume deployment // would need real pagination here. - const [xchangeRes, delayedRes, alertsRaw, subscriptionRows] = await Promise.all([ + const [xchangeRes, delayedRes, failuresRes, retrySummary, alertsRaw, subscriptionRows] = await Promise.all([ get>( `/xchanges?filter=${encodeURIComponent(`StartedOn:6:${new Date(windowStart).toISOString()}`)}&size=1000&sort=StartedOn:1`, ), get>("/delayedretries?size=1"), + // Counted by the search rather than from the rows above, for two reasons: those are capped + // at a page of 1000 and at 14 days, and this number has to agree with the list the tile + // opens — which carries no date bound. Only the total is wanted, so no rows are fetched. + get>( + `/xchanges?filter=${encodeURIComponent("StatusFilter:1:3")}&filter=${encodeURIComponent("LatestOnly:1:true")}&size=1`, + ), + get("/dashboard/retrysummary"), // Depends on RabbitMQ management being configured on the backend — don't let it take the // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. get("/ops/alerts").catch(() => null), @@ -117,6 +136,9 @@ export const dashboardMethods = { processing: todayRows.filter(isProcessing).length, }, yesterdayTotal: yesterdayRows.length, + failuresToActOn: failuresRes.totalCount, + failingChains: retrySummary.failingChains ?? [], + retriesLast7Days: retrySummary.retriesLast7Days ?? { finished: 0, succeeded: 0 }, successRate7d, pendingRetries: delayedRes.totalCount, queueAlerts: alertsRaw === null ? null : alertsRaw.filter((a) => a.severity !== "Info").length, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index b8dfba6b..b04e1284 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -175,6 +175,7 @@ function buildExchangeFilters(query: ExchangeQuery): URLSearchParams { params.append("filter", `Id:4:text|${ids.join("|")}`); } if (query.correlationId?.trim()) params.append("filter", `CorrelationId:1:${query.correlationId.trim()}`); + if (query.latest) params.append("filter", "LatestOnly:1:true"); // PromotedPropertiesRaw is stored as "key:value,key:value", so prefixing the key turns // the same substring match into a scoped one — no schema or endpoint change needed. // Typing "merchant:Acme" into the value box has therefore always worked; the picker diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 22f415a8..397a54fd 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -936,6 +936,11 @@ export interface ExchangeQuery { /** Comma/pipe/newline separated; matches id, retryFor OR aggregationXchangeId. */ ids?: string; correlationId?: string; + /** + * Only the newest attempt of each retry chain. A chain is one piece of work however many + * attempts it took, so this turns a list of failures into a list of things still to deal with. + */ + latest?: boolean; /** Substring match against promoted property keys and values. */ property?: string; /** @@ -1094,7 +1099,34 @@ export interface QueueHealthSnapshot { // ——— Dashboard ——— +/** One chain that has been retried repeatedly and is still failing. */ +export interface FailingChain { + /** The newest attempt — where this chain got to, and the one a retry continues from. */ + id: string; + /** How many attempts it has taken, counting the original as one. */ + attempts: number; + subscriptionId: number | null; + subscriptionName: string | null; + informationTypeCode: string | null; + startedOn: string; + exception: string | null; +} + export interface DashboardData { + /** Worst first. Empty when nothing has been retried more than once and left failing. */ + failingChains: FailingChain[]; + /** + * Retries started in the last 7 days: how many have finished, and how many of those worked. + * Says whether retrying achieves anything here, which is what makes a long chain either bad + * luck or a configuration problem. + */ + retriesLast7Days: { finished: number; succeeded: number }; + /** + * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a + * chain retried nine times is one of these, not nine. Subject to the search's count cap, so a + * value above it means "at least this many". + */ + failuresToActOn: number; today: { total: number; failed: number; processing: number }; yesterdayTotal: number; /** Percentage 0–100 across the last 7 days of finished exchanges. */ diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index 34bdc744..b87908d4 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -20,16 +20,20 @@ function StatTile({ sub, to, accent, + title, }: { label: string; value: ReactNode; sub?: ReactNode; to: string; accent?: "danger" | "warn"; + /** For a number whose meaning its label cannot carry on its own. */ + title?: string; }) { return ( {/* — KPI row — */} -
+
0 ? "danger" : undefined} /> + {/* "Failed today" counts attempts, and a chain retried nine times is nine of them — + none of which is the current state of anything. This counts the ends of chains, so + it is the number of problems, and it opens exactly that list. */} + 10_000 ? "10,000+" : data.failuresToActOn} + sub={data.failuresToActOn > 0 ? "newest attempt of each chain" : "every failure was retried"} + to="/exchanges?status=failed&latest=1" + accent={data.failuresToActOn > 0 ? "danger" : undefined} + title="Failures that nothing has been retried from yet, over the last 14 days. A chain of nine failed attempts counts once — as the attempt at the end of it, which is the one still to deal with." + /> + {/* — chains that keep failing — + "Latest failures" answers what broke most recently. This answers which of them is not + getting better however often it is retried — the difference between bad luck and + something a person has to go and fix. The retry success rate sits in the description, + because a long chain only means "broken" where retries usually work. */} + + {data.failingChains.length === 0 ? ( + + Nothing has been retried more than once and left failing. + + ) : ( +
    + {data.failingChains.map((c) => ( +
  • +
    + + + {c.attempts} attempts + + + {c.informationTypeCode && ( + {c.informationTypeCode} + )} + {c.subscriptionName && ( + + {c.subscriptionName} + + )} + {timeAgo(c.startedOn)} +
    + {c.exception && ( +

    + {c.exception.split("\n")[0]} +

    + )} +
  • + ))} +
+ )} + + All failures to act on → + +
+
{/* — latest failures — */} diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index b997ae32..395cb3a0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, ChevronRight, Plus, RotateCcw, X } from "lucide-react"; +import { ChevronDown, ChevronRight, Layers, Plus, RotateCcw, X } from "lucide-react"; import { api, type BulkRetrySelection, type ExchangeQuery, type ExchangeStatus } from "../../api"; import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; @@ -35,7 +35,7 @@ const REFRESH_OPTIONS = [ ]; /** Everything except paging counts as "a filter" for the Clear affordance. */ -const FILTER_KEYS = ["status", "subscriptionId", "partnerId", "informationTypeId", "ids", "correlationId", "propertyKey", "property", "from", "to"] as const; +const FILTER_KEYS = ["status", "subscriptionId", "partnerId", "informationTypeId", "ids", "correlationId", "propertyKey", "property", "from", "to", "latest"] as const; const readQuery = (sp: URLSearchParams): ExchangeQuery => ({ status: (sp.get("status") as ExchangeStatus | null) ?? undefined, @@ -44,6 +44,7 @@ const readQuery = (sp: URLSearchParams): ExchangeQuery => ({ informationTypeId: sp.get("informationTypeId") ? Number(sp.get("informationTypeId")) : undefined, ids: sp.get("ids") ?? undefined, correlationId: sp.get("correlationId") ?? undefined, + latest: sp.get("latest") === "1" || undefined, propertyKey: sp.get("propertyKey") ?? undefined, property: sp.get("property") ?? undefined, from: sp.get("from") ? new Date(sp.get("from")! + "T00:00:00").toISOString() : undefined, @@ -253,6 +254,27 @@ export function ExchangesPage() { ); })} + + {/* Separated from the status pills, which pick one of a set — this one narrows whatever + they picked. A chain is one piece of work however many attempts it took, so this is + what turns a list of failures into a list of things still to deal with. */} + + {/* Deliberately not shaped like the status pills beside it. Those pick one of a set; this + narrows whatever they picked, and as a sixth round pill it read as a seventh status. */} + + {refreshMs > 0 && (