This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit c9c22ca48923f88819d5baea78afdba5a8fe2c04
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;
     });

Reply via email to