From 31f1d92bf80f29091f9990deb98e38bed1f9fb7c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:14:00 +0000 Subject: [PATCH 1/5] fix(alerts): Show capped pagination counts as lower bounds Co-Authored-By: Nick Meisenheimer --- .../components/core/pagination/pagination.tsx | 4 ++-- static/app/views/automations/list.spec.tsx | 18 ++++++++++++++++++ static/app/views/automations/list.tsx | 5 +++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/static/app/components/core/pagination/pagination.tsx b/static/app/components/core/pagination/pagination.tsx index 4b5ea308e285..22086f6b8de9 100644 --- a/static/app/components/core/pagination/pagination.tsx +++ b/static/app/components/core/pagination/pagination.tsx @@ -115,7 +115,7 @@ export function getPaginationCaption({ cursor: string | string[] | undefined | null; limit: number; pageLength: number; - total: number; + total: React.ReactNode; }): React.ReactNode { if (pageLength === 0) { return ''; @@ -129,7 +129,7 @@ export function getPaginationCaption({ return tct('[start]-[end] of [total]', { start: start.toLocaleString(), end: end.toLocaleString(), - total: total.toLocaleString(), + total: typeof total === 'number' ? total.toLocaleString() : total, }); } diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index 0da895264313..be28a4eaddea 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -81,6 +81,24 @@ describe('AutomationsList', () => { expect(within(row).getByText('1 monitor')).toBeInTheDocument(); }); + it('displays capped result counts as a lower bound', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: Array.from({length: 20}, (_, index) => + AutomationFixture({id: `${index}`, name: `Automation ${index}`, detectorIds: []}) + ), + headers: { + Link: '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:1:0"', + 'X-Hits': '1000', + 'X-Max-Hits': '1000', + }, + }); + + render(, {organization}); + + expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); + }); + it('displays connected detectors and projects via a single batch request', async () => { const project2 = ProjectFixture({id: '2', slug: 'project-2'}); const detector2 = MetricDetectorFixture({ diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index a870bf702978..bb69f88ba13f 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -6,6 +6,7 @@ import {Flex} from '@sentry/scraps/layout'; import {getPaginationCaption, Pagination} from '@sentry/scraps/pagination'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {QueryCount} from 'sentry/components/queryCount'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {AlertsMonitorsShowcaseButton} from 'sentry/components/workflowEngine/alertsMonitorsShowcaseButton'; import {WorkflowEngineListLayout as ListLayout} from 'sentry/components/workflowEngine/layout/list'; @@ -60,7 +61,7 @@ export default function AutomationsList() { cursor, limit: AUTOMATION_LIST_PAGE_LIMIT, pageLength: automations.length, - total: hits, + total: , }); return ( @@ -86,7 +87,7 @@ export default function AutomationsList() { isError={isError} isSuccess={isSuccess} sort={sort} - queryCount={hits > maxHits ? `${maxHits}+` : `${hits}`} + queryCount={hits >= maxHits ? `${maxHits}+` : `${hits}`} allResultsVisible={allResultsVisible()} /> From 4b81a9535a8b8c2da7680c8bd8075e55f853ec5e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:20:10 +0000 Subject: [PATCH 2/5] ref(alerts): Scope capped count fix to alerts list Co-Authored-By: Nick Meisenheimer --- .../components/core/pagination/pagination.tsx | 4 ++-- static/app/views/automations/list.spec.tsx | 2 +- static/app/views/automations/list.tsx | 16 +++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/static/app/components/core/pagination/pagination.tsx b/static/app/components/core/pagination/pagination.tsx index 22086f6b8de9..4b5ea308e285 100644 --- a/static/app/components/core/pagination/pagination.tsx +++ b/static/app/components/core/pagination/pagination.tsx @@ -115,7 +115,7 @@ export function getPaginationCaption({ cursor: string | string[] | undefined | null; limit: number; pageLength: number; - total: React.ReactNode; + total: number; }): React.ReactNode { if (pageLength === 0) { return ''; @@ -129,7 +129,7 @@ export function getPaginationCaption({ return tct('[start]-[end] of [total]', { start: start.toLocaleString(), end: end.toLocaleString(), - total: typeof total === 'number' ? total.toLocaleString() : total, + total: total.toLocaleString(), }); } diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index be28a4eaddea..f616d5d44731 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -96,7 +96,7 @@ describe('AutomationsList', () => { render(, {organization}); - expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); + expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1,000+'); }); it('displays connected detectors and projects via a single batch request', async () => { diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index bb69f88ba13f..388fb1e20f19 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react'; +import {Fragment, useCallback} from 'react'; import {useQuery} from '@tanstack/react-query'; import {LinkButton} from '@sentry/scraps/button'; @@ -6,7 +6,6 @@ import {Flex} from '@sentry/scraps/layout'; import {getPaginationCaption, Pagination} from '@sentry/scraps/pagination'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; -import {QueryCount} from 'sentry/components/queryCount'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {AlertsMonitorsShowcaseButton} from 'sentry/components/workflowEngine/alertsMonitorsShowcaseButton'; import {WorkflowEngineListLayout as ListLayout} from 'sentry/components/workflowEngine/layout/list'; @@ -55,14 +54,17 @@ export default function AutomationsList() { }, [pageLinks]); const paginationCaption = - isLoading || !automations - ? undefined - : getPaginationCaption({ + isLoading || !automations ? undefined : ( + + {getPaginationCaption({ cursor, limit: AUTOMATION_LIST_PAGE_LIMIT, pageLength: automations.length, - total: , - }); + total: hits, + })} + {hits >= maxHits ? '+' : null} + + ); return ( From 1f338cdd847397f8fe19121021bfb9600088afca Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:26:10 +0000 Subject: [PATCH 3/5] fix(alerts): Put capped count inside pagination total Keep the lower-bound marker in the [total] slot so translations cannot leave the + stranded, without changing the shared pagination helper. --- static/app/views/automations/list.spec.tsx | 2 +- static/app/views/automations/list.tsx | 32 ++++++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index f616d5d44731..be28a4eaddea 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -96,7 +96,7 @@ describe('AutomationsList', () => { render(, {organization}); - expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1,000+'); + expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); }); it('displays connected detectors and projects via a single batch request', async () => { diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index 388fb1e20f19..7d213ecf7118 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -1,17 +1,19 @@ -import {Fragment, useCallback} from 'react'; +import {useCallback} from 'react'; import {useQuery} from '@tanstack/react-query'; import {LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; -import {getPaginationCaption, Pagination} from '@sentry/scraps/pagination'; +import {Pagination} from '@sentry/scraps/pagination'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {QueryCount} from 'sentry/components/queryCount'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {AlertsMonitorsShowcaseButton} from 'sentry/components/workflowEngine/alertsMonitorsShowcaseButton'; import {WorkflowEngineListLayout as ListLayout} from 'sentry/components/workflowEngine/layout/list'; import {IconAdd} from 'sentry/icons'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; import {selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; +import {parseCursor} from 'sentry/utils/cursor'; import {parseLinkHeader} from 'sentry/utils/parseLinkHeader'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; import {useLocation} from 'sentry/utils/useLocation'; @@ -53,18 +55,18 @@ export default function AutomationsList() { return links && !links.previous!.results && !links.next!.results; }, [pageLinks]); - const paginationCaption = - isLoading || !automations ? undefined : ( - - {getPaginationCaption({ - cursor, - limit: AUTOMATION_LIST_PAGE_LIMIT, - pageLength: automations.length, - total: hits, - })} - {hits >= maxHits ? '+' : null} - - ); + let paginationCaption: React.ReactNode; + if (!isLoading && automations && automations.length > 0) { + const offset = parseCursor(cursor)?.offset ?? 0; + const start = offset * AUTOMATION_LIST_PAGE_LIMIT + 1; + const end = start + automations.length - 1; + + paginationCaption = tct('[start]-[end] of [total]', { + start: start.toLocaleString(), + end: end.toLocaleString(), + total: , + }); + } return ( From 749123fd35c7ac2b110edd90822de1b1626c2f7e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:35:10 +0000 Subject: [PATCH 4/5] fix(alerts): Detect capped totals without X-Max-Hits When pagination walks past the reported X-Hits count, treat the total as a lower bound even if X-Max-Hits is missing from the response. Co-Authored-By: Nick Meisenheimer --- static/app/views/automations/list.spec.tsx | 27 ++++++++++++++++++++++ static/app/views/automations/list.tsx | 22 +++++++++++------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index be28a4eaddea..1787c0ef5ae2 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -99,6 +99,33 @@ describe('AutomationsList', () => { expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); }); + it('treats totals as capped when the page starts past X-Hits', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: Array.from({length: 20}, (_, index) => + AutomationFixture({id: `${index}`, name: `Automation ${index}`, detectorIds: []}) + ), + headers: { + Link: '; rel="previous"; results="true"; cursor="0:50:1", ; rel="next"; results="true"; cursor="0:52:0"', + 'X-Hits': '1000', + }, + }); + + render(, { + organization, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/monitors/alerts/', + query: {cursor: '0:51:0'}, + }, + }, + }); + + expect(await screen.findByTestId('pagination')).toHaveTextContent( + '1,021-1,040 of 1000+' + ); + }); + it('displays connected detectors and projects via a single batch request', async () => { const project2 = ProjectFixture({id: '2', slug: 'project-2'}); const detector2 = MetricDetectorFixture({ diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index 7d213ecf7118..9a642fcbdb51 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -43,8 +43,7 @@ export default function AutomationsList() { const automations = data?.json; const hits = data?.headers['X-Hits'] ?? 0; - // If maxHits is not set, we assume there is no max - const maxHits = data?.headers['X-Max-Hits'] ?? Infinity; + const maxHits = data?.headers['X-Max-Hits']; const pageLinks = data?.headers.Link; const allResultsVisible = useCallback(() => { @@ -55,16 +54,23 @@ export default function AutomationsList() { return links && !links.previous!.results && !links.next!.results; }, [pageLinks]); + const offset = parseCursor(cursor)?.offset ?? 0; + const pageStart = offset * AUTOMATION_LIST_PAGE_LIMIT + 1; + // If the page starts past the reported hit count, the total is a lower bound + // even when X-Max-Hits is missing from the response. + const isCappedTotal = + (typeof maxHits === 'number' && hits >= maxHits) || pageStart > hits; + const cappedTotal = isCappedTotal ? (maxHits ?? hits) : undefined; + const queryCount = isCappedTotal ? `${cappedTotal}+` : `${hits}`; + let paginationCaption: React.ReactNode; if (!isLoading && automations && automations.length > 0) { - const offset = parseCursor(cursor)?.offset ?? 0; - const start = offset * AUTOMATION_LIST_PAGE_LIMIT + 1; - const end = start + automations.length - 1; + const end = pageStart + automations.length - 1; paginationCaption = tct('[start]-[end] of [total]', { - start: start.toLocaleString(), + start: pageStart.toLocaleString(), end: end.toLocaleString(), - total: , + total: , }); } @@ -91,7 +97,7 @@ export default function AutomationsList() { isError={isError} isSuccess={isSuccess} sort={sort} - queryCount={hits >= maxHits ? `${maxHits}+` : `${hits}`} + queryCount={queryCount} allResultsVisible={allResultsVisible()} /> From 0503fc34d66381ffe0556c5376d0d9633b84a357 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:49:54 +0000 Subject: [PATCH 5/5] fix(alerts): Fall back to default hit ceiling without X-Max-Hits OffsetPaginator omits X-Max-Hits, so early Alerts pages never knew the 1000-row ceiling. Default maxHits to 1000 so first-page totals can still render as lower bounds. Co-Authored-By: Nick Meisenheimer --- static/app/views/automations/list.spec.tsx | 17 +++++++++++++++++ static/app/views/automations/list.tsx | 13 +++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index 1787c0ef5ae2..8d3a458e0c15 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -99,6 +99,23 @@ describe('AutomationsList', () => { expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); }); + it('falls back to the default hit ceiling when X-Max-Hits is missing', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: Array.from({length: 20}, (_, index) => + AutomationFixture({id: `${index}`, name: `Automation ${index}`, detectorIds: []}) + ), + headers: { + Link: '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:1:0"', + 'X-Hits': '1000', + }, + }); + + render(, {organization}); + + expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); + }); + it('treats totals as capped when the page starts past X-Hits', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/workflows/', diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index 9a642fcbdb51..ba1de3c5528c 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -43,7 +43,9 @@ export default function AutomationsList() { const automations = data?.json; const hits = data?.headers['X-Hits'] ?? 0; - const maxHits = data?.headers['X-Max-Hits']; + // OffsetPaginator currently omits X-Max-Hits. Fall back to the server's default + // hit ceiling so early pages can still render lower-bound totals like "1000+". + const maxHits = data?.headers['X-Max-Hits'] ?? 1000; const pageLinks = data?.headers.Link; const allResultsVisible = useCallback(() => { @@ -56,11 +58,10 @@ export default function AutomationsList() { const offset = parseCursor(cursor)?.offset ?? 0; const pageStart = offset * AUTOMATION_LIST_PAGE_LIMIT + 1; - // If the page starts past the reported hit count, the total is a lower bound - // even when X-Max-Hits is missing from the response. - const isCappedTotal = - (typeof maxHits === 'number' && hits >= maxHits) || pageStart > hits; - const cappedTotal = isCappedTotal ? (maxHits ?? hits) : undefined; + // Also treat deep pages past the reported hit count as capped, in case the + // response omits both a useful max and a stable ceiling. + const isCappedTotal = hits >= maxHits || pageStart > hits; + const cappedTotal = isCappedTotal ? maxHits : undefined; const queryCount = isCappedTotal ? `${cappedTotal}+` : `${hits}`; let paginationCaption: React.ReactNode;