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() { 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 && (