diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs
index a3767259..a67af1e9 100644
--- a/SW.Bitween.Api/Resources/Xchanges/Search.cs
+++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs
@@ -14,6 +14,13 @@ namespace SW.Bitween.Resources.Xchanges
{
public class Search : ISearchyHandler
{
+ ///
+ /// Largest exact total the exchange search reports. Beyond it the response carries
+ /// CountCap + 1, meaning "more than this" — the client renders that as "10,000+".
+ /// Kept in step with COUNT_CAP in ClientApp's ExchangesPage.tsx.
+ ///
+ internal const int CountCap = 10_000;
+
private readonly BitweenDbContext dbContext;
private readonly RequestContext requestContext;
private readonly XchangeService xchangeService;
@@ -177,7 +184,17 @@ from delayedRetry in drGroup.DefaultIfEmpty()
var searchyResponse = new SearchyResponse
{
Result = r,
- TotalCount = await query.AsNoTracking().Search(searchyRequest.Conditions).CountAsync()
+ // Counting every match is what a filtered search now spends its time on: the rows
+ // themselves come back in a few milliseconds, while an exact count has to visit
+ // every matching row because it cannot stop early. Measured on 1M exchanges, the
+ // Success pill's count was 264ms against 0.5ms for the rows.
+ //
+ // Stop counting past the cap and report the cap + 1 instead, which the client shows
+ // as "10,000+". 36ms drops to 2.3ms unfiltered, 264ms to 25ms on Success. Anything
+ // filtered narrowly enough to act on still gets an exact number; only views far too
+ // broad to page through lose it, and 10,000 rows is 400 pages of Next.
+ TotalCount = await query.AsNoTracking().Search(searchyRequest.Conditions)
+ .Take(CountCap + 1).CountAsync()
};
return searchyResponse;
diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
index 7d6f76fe..e8446b8b 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
@@ -18,6 +18,14 @@ import { keys } from "../../api/queryKeys";
const PAGE_SIZE = 25;
const STATUSES: ExchangeStatus[] = ["processing", "success", "badResponse", "failed"];
+/**
+ * The backend stops counting matches past this and returns `COUNT_CAP + 1` instead, because an
+ * exact total has to visit every matching row and was costing more than fetching the rows did.
+ * So a total above the cap means "at least this many", shown as "10,000+".
+ * Kept in step with `CountCap` in Xchanges/Search.cs.
+ */
+const COUNT_CAP = 10_000;
+
const REFRESH_OPTIONS = [
{ value: "0", label: "Refresh: off" },
{ value: "5000", label: "Refresh: 5s" },
@@ -113,6 +121,7 @@ export function ExchangesPage() {
const rows = data?.result ?? [];
const total = data?.total ?? 0;
+ const totalIsCapped = total > COUNT_CAP;
const allOnPageSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
const bulkRetry = useMutation({
@@ -298,10 +307,27 @@ export function ExchangesPage() {
{isLoading ? (
) : rows.length === 0 ? (
-
- {activeFilterCount > 0
- ? "Try removing some filters — or widen the date range."
- : "Traffic will show up here as soon as a subscription processes something."}
+ /* An empty page and an empty search are different problems. This branch replaces the
+ table, paging footer and all, so a page past the end of the list would otherwise
+ leave nothing to click back to. Reachable two ways: a hand-typed ?offset=, and Next
+ past the count cap, where a last page that happens to be full still enables it. */
+ 0 ? "Nothing on this page" : "No exchanges match"}
+ action={
+ query.offset > 0 ? (
+
+ ) : undefined
+ }
+ >
+ {query.offset > 0
+ ? "The list ends before this page."
+ : activeFilterCount > 0
+ ? "Try removing some filters — or widen the date range."
+ : "Traffic will show up here as soon as a subscription processes something."}
) : (