This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-2-test by this push:
new 73f555f0ab5 Feature/cursor pagination task instances UI (#64953)
(#65542)
73f555f0ab5 is described below
commit 73f555f0ab5096abca69c725de14de725b1a73e3
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Apr 20 19:35:03 2026 +0200
Feature/cursor pagination task instances UI (#64953) (#65542)
* UI: Switch TaskInstances table to cursor-based pagination
Replace offset-based pagination with cursor-based pagination
for the TaskInstances listing page, leveraging the new cursor
API endpoint. Pagination now shows only previous/next buttons
without page numbers or total count, which eliminates the
expensive COUNT(*) query for large datasets.
Add generic cursor pagination support to DataTable via an
optional cursorPagination prop so other tables can adopt it.
* Small adjustments and cleaning
* Address review: cursor in TableState, no useCallback/useEffect
* Reuse Pagination.Root for cursor pagination instead of custom buttons
* Small updates
(cherry picked from commit 44ae2bc5efb5c5fc53b949c90c4e1b24a72cbc8d)
---
.../components/ActionAccordion/ActionAccordion.tsx | 2 +-
.../ui/src/components/DataTable/DataTable.tsx | 54 ++++++++++++++++++++--
.../ui/src/components/DataTable/searchParams.ts | 20 +++++++-
.../airflow/ui/src/components/DataTable/types.ts | 1 +
.../src/airflow/ui/src/constants/searchParams.ts | 1 +
.../ui/src/pages/TaskInstances/TaskInstances.tsx | 11 +++--
.../src/airflow/ui/src/utils/useFiltersHandler.ts | 2 +
7 files changed, 82 insertions(+), 9 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
index c04b8d79f73..d3f190c7ebc 100644
---
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
+++
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
@@ -65,7 +65,7 @@ const ActionAccordion = ({ affectedTasks, note, setNote }:
Props) => {
displayMode="table"
modelName="common:taskInstance"
noRowsMessage={translate("dags:runAndTaskActions.affectedTasks.noItemsFound")}
- total={affectedTasks.total_entries}
+ total={affectedTasks.total_entries ?? 0}
/>
</Box>
</Accordion.ItemContent>
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
index 4ec00ff62f2..81f5b42d671 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Heading, HStack, Text } from "@chakra-ui/react";
+import { Box, Heading, HStack, IconButton, Text } from "@chakra-ui/react";
import {
getCoreRowModel,
getExpandedRowModel,
@@ -31,6 +31,7 @@ import {
} from "@tanstack/react-table";
import React, { type ReactNode, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
+import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
import { useLocalStorage } from "usehooks-ts";
import { CardList } from "src/components/DataTable/CardList";
@@ -52,9 +53,11 @@ type DataTableProps<TData> = {
readonly isFetching?: boolean;
readonly isLoading?: boolean;
readonly modelName: string;
+ readonly nextCursor?: string | null;
readonly noRowsMessage?: ReactNode;
readonly onDisplayToggleChange?: (mode: "card" | "table") => void;
readonly onStateChange?: (state: TableState) => void;
+ readonly previousCursor?: string | null;
readonly renderSubComponent?: (props: { row: Row<TData> }) =>
React.ReactElement;
readonly showDisplayToggle?: boolean;
readonly showRowCountHeading?: boolean;
@@ -76,9 +79,11 @@ export const DataTable = <TData,>({
isFetching,
isLoading,
modelName,
+ nextCursor,
noRowsMessage,
onDisplayToggleChange,
onStateChange,
+ previousCursor,
showDisplayToggle,
showRowCountHeading = true,
skeletonCount = 10,
@@ -148,7 +153,13 @@ export const DataTable = <TData,>({
const display = displayMode === "card" && Boolean(cardDef) ? "card" :
"table";
const hasRows = rows.length > 0;
- const hasPagination = initialState?.pagination !== undefined && (pageIndex
!== 0 || rows.length !== total);
+ const hasNext = nextCursor !== undefined && nextCursor !== null;
+ const hasPrevious = previousCursor !== undefined && previousCursor !== null;
+ const hasCursorPagination = hasNext || hasPrevious;
+ const hasOffsetPagination =
+ !hasCursorPagination &&
+ initialState?.pagination !== undefined &&
+ (pageIndex !== 0 || rows.length !== total);
// Default to show columns filter only if there are actually many columns
displayed
const showColumnsFilter = allowFiltering ?? columns.length > 5;
@@ -158,7 +169,7 @@ export const DataTable = <TData,>({
[modelName, translate],
);
const showRowCount = Boolean(
- showRowCountHeading && !Boolean(isLoading) && !Boolean(isFetching) &&
total > 0,
+ showRowCountHeading && !hasCursorPagination && !Boolean(isLoading) &&
!Boolean(isFetching) && total > 0,
);
const noRowsModelName = translateModelName(0);
@@ -190,7 +201,7 @@ export const DataTable = <TData,>({
</Text>
)}
</Box>
- {hasPagination ? (
+ {hasOffsetPagination ? (
<Pagination.Root
count={rowCount}
my={2}
@@ -206,6 +217,41 @@ export const DataTable = <TData,>({
</HStack>
</Pagination.Root>
) : undefined}
+ {/* Pagination.Root is designed for offset-based pagination and requires
count/page/pageSize.
+ Cursor pagination doesn't have these values, and passing fake values
causes
+ incorrect disabled styling on the triggers. Use plain IconButtons
instead. */}
+ {hasCursorPagination && initialState ? (
+ <HStack my={2}>
+ <IconButton
+ aria-label="Previous page"
+ data-testid="prev"
+ disabled={!hasPrevious}
+ onClick={() => {
+ if (onStateChange && previousCursor !== undefined &&
previousCursor !== null) {
+ onStateChange({ ...initialState, cursor: previousCursor });
+ }
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ <HiChevronLeft />
+ </IconButton>
+ <IconButton
+ aria-label="Next page"
+ data-testid="next"
+ disabled={!hasNext}
+ onClick={() => {
+ if (onStateChange && nextCursor !== undefined && nextCursor !==
null) {
+ onStateChange({ ...initialState, cursor: nextCursor });
+ }
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ <HiChevronRight />
+ </IconButton>
+ </HStack>
+ ) : undefined}
</Box>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
b/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
index f1923e90e22..85ee6cc688f 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/searchParams.ts
@@ -22,7 +22,12 @@ import { SearchParamsKeys, type SearchParamsKeysType } from
"src/constants/searc
import type { TableState } from "./types";
-const { LIMIT: LIMIT_PARAM, OFFSET: OFFSET_PARAM, SORT: SORT_PARAM }:
SearchParamsKeysType = SearchParamsKeys;
+const {
+ CURSOR: CURSOR_PARAM,
+ LIMIT: LIMIT_PARAM,
+ OFFSET: OFFSET_PARAM,
+ SORT: SORT_PARAM,
+}: SearchParamsKeysType = SearchParamsKeys;
export const stateToSearchParams = (state: TableState, defaultTableState?:
TableState): URLSearchParams => {
const queryParams = new URLSearchParams(globalThis.location.search);
@@ -39,6 +44,12 @@ export const stateToSearchParams = (state: TableState,
defaultTableState?: Table
queryParams.set(OFFSET_PARAM, `${state.pagination.pageIndex}`);
}
+ if (state.cursor !== undefined && state.cursor !== "") {
+ queryParams.set(CURSOR_PARAM, state.cursor);
+ } else {
+ queryParams.delete(CURSOR_PARAM);
+ }
+
if (state.sorting.length) {
state.sorting.forEach(({ desc, id }) => {
if (defaultTableState?.sorting.find((sort) => sort.id === id &&
sort.desc === desc)) {
@@ -68,6 +79,13 @@ export const searchParamsToState = (searchParams:
URLSearchParams, defaultState:
},
};
}
+
+ const cursorValue = searchParams.get(CURSOR_PARAM);
+
+ if (cursorValue !== null) {
+ urlState = { ...urlState, cursor: cursorValue };
+ }
+
const sorts = searchParams.getAll(SORT_PARAM);
const sorting: SortingState = sorts.map((sort) => ({
desc: sort.startsWith("-"),
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/types.ts
b/airflow-core/src/airflow/ui/src/components/DataTable/types.ts
index 5e2c047db6f..e1ce19668a6 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/types.ts
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/types.ts
@@ -22,6 +22,7 @@ import type { JSX, ReactNode } from "react";
export type TableState = {
columnVisibility?: VisibilityState;
+ cursor?: string;
pagination: PaginationState;
sorting: SortingState;
};
diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index 7194fe69cee..627227a04c1 100644
--- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
@@ -27,6 +27,7 @@ export enum SearchParamsKeys {
CREATED_AT_GTE = "created_at_gte",
CREATED_AT_LTE = "created_at_lte",
CREATED_AT_RANGE = "created_at_range",
+ CURSOR = "cursor",
DAG_DISPLAY_NAME_PATTERN = "dag_display_name_pattern",
DAG_ID = "dag_id",
DAG_ID_PATTERN = "dag_id_pattern",
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index f897ad4c28d..7c5f6287649 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -223,6 +223,7 @@ export const TaskInstances = () => {
const { t: translate } = useTranslation();
const { dagId, groupId, runId, taskId } = useParams();
const [searchParams] = useSearchParams();
+
const { setTableURLState, tableURLState } = useTableURLState({
columnVisibility: {
dag_version: false,
@@ -233,7 +234,7 @@ export const TaskInstances = () => {
queue: false,
},
});
- const { pagination, sorting } = tableURLState;
+ const { cursor, pagination, sorting } = tableURLState;
const [sort] = sorting;
const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : ["-id"];
@@ -259,6 +260,7 @@ export const TaskInstances = () => {
const { data, error, isLoading } = useTaskInstanceServiceGetTaskInstances(
{
+ cursor: cursor ?? "",
dagId: dagId ?? "~",
dagIdPattern: filteredDagIdPattern ?? undefined,
dagRunId: runId ?? "~",
@@ -269,7 +271,6 @@ export const TaskInstances = () => {
logicalDateGte: logicalDateGte ?? undefined,
logicalDateLte: logicalDateLte ?? undefined,
mapIndex: mapIndexFilter !== null && mapIndexFilter !== "" ?
[Number(mapIndexFilter)] : undefined,
- offset: pagination.pageIndex * pagination.pageSize,
operatorNamePattern: operatorNamePattern ?? undefined,
orderBy,
poolNamePattern: poolNamePattern ?? undefined,
@@ -292,6 +293,9 @@ export const TaskInstances = () => {
},
);
+ const nextCursor = data && "next_cursor" in data ? data.next_cursor :
undefined;
+ const previousCursor = data && "previous_cursor" in data ?
data.previous_cursor : undefined;
+
const columns = taskInstanceColumns({
dagId,
runId,
@@ -309,8 +313,9 @@ export const TaskInstances = () => {
initialState={tableURLState}
isLoading={isLoading}
modelName="common:taskInstance"
+ nextCursor={nextCursor}
onStateChange={setTableURLState}
- total={data?.total_entries ?? undefined}
+ previousCursor={previousCursor}
/>
</>
);
diff --git a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
index 6df86f6f6ab..f5dd2b0101a 100644
--- a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
+++ b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
@@ -145,6 +145,7 @@ export const useFiltersHandler = (searchParamKeys:
Array<FilterableSearchParamsK
const handleFiltersChange = (filters: Record<string, FilterValue>) => {
setTableURLState({
+ cursor: undefined,
pagination: { ...pagination, pageIndex: 0 },
sorting,
});
@@ -167,6 +168,7 @@ export const useFiltersHandler = (searchParamKeys:
Array<FilterableSearchParamsK
});
newParams.delete(SearchParamsKeys.OFFSET);
+ newParams.delete(SearchParamsKeys.CURSOR);
return newParams;
});