Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion SW.Bitween.Api/Resources/Xchanges/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ namespace SW.Bitween.Resources.Xchanges
{
public class Search : ISearchyHandler
{
/// <summary>
/// Largest exact total the exchange search reports. Beyond it the response carries
/// <c>CountCap + 1</c>, meaning "more than this" — the client renders that as "10,000+".
/// Kept in step with <c>COUNT_CAP</c> in ClientApp's <c>ExchangesPage.tsx</c>.
/// </summary>
internal const int CountCap = 10_000;

private readonly BitweenDbContext dbContext;
private readonly RequestContext requestContext;
private readonly XchangeService xchangeService;
Expand Down Expand Up @@ -177,7 +184,17 @@ from delayedRetry in drGroup.DefaultIfEmpty()
var searchyResponse = new SearchyResponse<XchangeRow>
{
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;
Expand Down
48 changes: 42 additions & 6 deletions SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -298,10 +307,27 @@ export function ExchangesPage() {
{isLoading ? (
<LoadingBlock />
) : rows.length === 0 ? (
<EmptyState title="No exchanges match">
{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. */
<EmptyState
title={query.offset > 0 ? "Nothing on this page" : "No exchanges match"}
action={
query.offset > 0 ? (
<Button
onClick={() => setParam("offset", String(Math.max(0, query.offset - PAGE_SIZE)), false)}
>
Back a page
</Button>
) : 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."}
</EmptyState>
) : (
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-white">
Expand Down Expand Up @@ -439,7 +465,14 @@ export function ExchangesPage() {
{/* — paging — */}
<div className="flex items-center justify-between border-t border-ink-100 px-4 py-2.5 text-[13px] text-ink-500">
<span>
Showing {query.offset + 1}–{Math.min(query.offset + PAGE_SIZE, total)} of {total}
Showing {query.offset + 1}–{query.offset + rows.length} of{" "}
{totalIsCapped ? (
<span title={`More than ${COUNT_CAP.toLocaleString()} match — narrow the filters for an exact count`}>
{COUNT_CAP.toLocaleString()}+
</span>
) : (
total.toLocaleString()
)}
</span>
<span className="flex gap-1.5">
<Button
Expand All @@ -451,7 +484,10 @@ export function ExchangesPage() {
</Button>
<Button
size="sm"
disabled={query.offset + PAGE_SIZE >= total}
/* Past the cap the total no longer says where the end is, so fall back to "was this
page full" — otherwise Next would dead-end at row 10,000. Below the cap the total
is exact, so keep using it and Next stops on the true last page. */
disabled={totalIsCapped ? rows.length < PAGE_SIZE : query.offset + PAGE_SIZE >= total}
Comment thread
hamzahalq marked this conversation as resolved.
onClick={() => setParam("offset", String(query.offset + PAGE_SIZE), false)}
>
Next
Expand Down
Loading