Skip to content

Commit a5135d0

Browse files
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.
1 parent 2df0f71 commit a5135d0

3 files changed

Lines changed: 78 additions & 6 deletions

File tree

airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import { Box, Heading, HStack, Text } from "@chakra-ui/react";
19+
import { Box, Heading, HStack, IconButton, Text } from "@chakra-ui/react";
2020
import {
2121
getCoreRowModel,
2222
getExpandedRowModel,
@@ -31,6 +31,7 @@ import {
3131
} from "@tanstack/react-table";
3232
import React, { type ReactNode, useRef, useCallback } from "react";
3333
import { useTranslation } from "react-i18next";
34+
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
3435
import { useLocalStorage } from "usehooks-ts";
3536

3637
import { CardList } from "src/components/DataTable/CardList";
@@ -40,10 +41,18 @@ import { createSkeletonMock } from "src/components/DataTable/skeleton";
4041
import type { CardDef, MetaColumn, TableState } from "src/components/DataTable/types";
4142
import { ProgressBar, Pagination, Toaster } from "src/components/ui";
4243

44+
export type CursorPaginationProps = {
45+
readonly hasNext: boolean;
46+
readonly hasPrevious: boolean;
47+
readonly onNext: () => void;
48+
readonly onPrevious: () => void;
49+
};
50+
4351
type DataTableProps<TData> = {
4452
readonly allowFiltering?: boolean;
4553
readonly cardDef?: CardDef<TData>;
4654
readonly columns: Array<MetaColumn<TData>>;
55+
readonly cursorPagination?: CursorPaginationProps;
4756
readonly data: Array<TData>;
4857
readonly displayMode?: "card" | "table";
4958
readonly errorMessage?: ReactNode | string;
@@ -68,6 +77,7 @@ export const DataTable = <TData,>({
6877
allowFiltering,
6978
cardDef,
7079
columns,
80+
cursorPagination,
7181
data,
7282
displayMode = "table",
7383
errorMessage,
@@ -148,7 +158,10 @@ export const DataTable = <TData,>({
148158

149159
const display = displayMode === "card" && Boolean(cardDef) ? "card" : "table";
150160
const hasRows = rows.length > 0;
151-
const hasPagination = initialState?.pagination !== undefined && (pageIndex !== 0 || rows.length !== total);
161+
const hasOffsetPagination =
162+
!cursorPagination && initialState?.pagination !== undefined && (pageIndex !== 0 || rows.length !== total);
163+
const hasCursorPagination =
164+
cursorPagination !== undefined && (cursorPagination.hasNext || cursorPagination.hasPrevious);
152165

153166
// Default to show columns filter only if there are actually many columns displayed
154167
const showColumnsFilter = allowFiltering ?? columns.length > 5;
@@ -158,7 +171,7 @@ export const DataTable = <TData,>({
158171
[modelName, translate],
159172
);
160173
const showRowCount = Boolean(
161-
showRowCountHeading && !Boolean(isLoading) && !Boolean(isFetching) && total > 0,
174+
showRowCountHeading && !cursorPagination && !Boolean(isLoading) && !Boolean(isFetching) && total > 0,
162175
);
163176
const noRowsModelName = translateModelName(0);
164177

@@ -190,7 +203,7 @@ export const DataTable = <TData,>({
190203
</Text>
191204
)}
192205
</Box>
193-
{hasPagination ? (
206+
{hasOffsetPagination ? (
194207
<Pagination.Root
195208
count={rowCount}
196209
my={2}
@@ -206,6 +219,30 @@ export const DataTable = <TData,>({
206219
</HStack>
207220
</Pagination.Root>
208221
) : undefined}
222+
{hasCursorPagination ? (
223+
<HStack justifyContent="center" my={2}>
224+
<IconButton
225+
aria-label="Previous page"
226+
data-testid="cursor-prev"
227+
disabled={!cursorPagination.hasPrevious}
228+
onClick={cursorPagination.onPrevious}
229+
size="sm"
230+
variant="outline"
231+
>
232+
<HiChevronLeft />
233+
</IconButton>
234+
<IconButton
235+
aria-label="Next page"
236+
data-testid="cursor-next"
237+
disabled={!cursorPagination.hasNext}
238+
onClick={cursorPagination.onNext}
239+
size="sm"
240+
variant="outline"
241+
>
242+
<HiChevronRight />
243+
</IconButton>
244+
</HStack>
245+
) : undefined}
209246
</Box>
210247
);
211248
};

airflow-core/src/airflow/ui/src/components/DataTable/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@
1818
*/
1919

2020
export { DataTable } from "./DataTable";
21+
export type { CursorPaginationProps } from "./DataTable";

airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import { Flex, Link } from "@chakra-ui/react";
2222
import type { ColumnDef } from "@tanstack/react-table";
2323
import type { TFunction } from "i18next";
24+
import { useCallback, useEffect, useState } from "react";
2425
import { useTranslation } from "react-i18next";
2526
import { Link as RouterLink, useParams, useSearchParams } from "react-router-dom";
2627

@@ -29,6 +30,7 @@ import type { TaskInstanceResponse } from "openapi/requests/types.gen";
2930
import { ClearTaskInstanceButton } from "src/components/Clear";
3031
import { DagVersion } from "src/components/DagVersion";
3132
import { DataTable } from "src/components/DataTable";
33+
import type { CursorPaginationProps } from "src/components/DataTable";
3234
import { useRowSelection, type GetColumnsParams } from "src/components/DataTable/useRowSelection";
3335
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
3436
import { ErrorAlert } from "src/components/ErrorAlert";
@@ -274,6 +276,16 @@ export const TaskInstances = () => {
274276
const [sort] = sorting;
275277
const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : ["-id"];
276278

279+
const [cursor, setCursor] = useState<string>("");
280+
281+
// Reset cursor to the first page when filters or sort order change.
282+
const searchParamsKey = searchParams.toString();
283+
const sortKey = sort ? `${sort.desc ? "-" : ""}${sort.id}` : "";
284+
285+
useEffect(() => {
286+
setCursor("");
287+
}, [searchParamsKey, sortKey]);
288+
277289
const filteredState = searchParams.getAll(STATE_PARAM);
278290
const filteredDagVersion = searchParams.get(DAG_VERSION_PARAM);
279291
const durationGte = searchParams.get(DURATION_GTE_PARAM);
@@ -296,6 +308,7 @@ export const TaskInstances = () => {
296308

297309
const { data, error, isLoading } = useTaskInstanceServiceGetTaskInstances(
298310
{
311+
cursor,
299312
dagId: dagId ?? "~",
300313
dagIdPattern: filteredDagIdPattern ?? undefined,
301314
dagRunId: runId ?? "~",
@@ -306,7 +319,6 @@ export const TaskInstances = () => {
306319
logicalDateGte: logicalDateGte ?? undefined,
307320
logicalDateLte: logicalDateLte ?? undefined,
308321
mapIndex: mapIndexFilter !== null && mapIndexFilter !== "" ? [Number(mapIndexFilter)] : undefined,
309-
offset: pagination.pageIndex * pagination.pageSize,
310322
operatorNamePattern: operatorNamePattern ?? undefined,
311323
orderBy,
312324
poolNamePattern: poolNamePattern ?? undefined,
@@ -329,6 +341,28 @@ export const TaskInstances = () => {
329341
},
330342
);
331343

344+
const nextCursor = data && "next_cursor" in data ? data.next_cursor : undefined;
345+
const previousCursor = data && "previous_cursor" in data ? data.previous_cursor : undefined;
346+
347+
const handleNextPage = useCallback(() => {
348+
if (nextCursor !== undefined && nextCursor !== null) {
349+
setCursor(nextCursor);
350+
}
351+
}, [nextCursor]);
352+
353+
const handlePreviousPage = useCallback(() => {
354+
if (previousCursor !== undefined && previousCursor !== null) {
355+
setCursor(previousCursor);
356+
}
357+
}, [previousCursor]);
358+
359+
const cursorPagination: CursorPaginationProps = {
360+
hasNext: nextCursor !== undefined && nextCursor !== null,
361+
hasPrevious: previousCursor !== undefined && previousCursor !== null,
362+
onNext: handleNextPage,
363+
onPrevious: handlePreviousPage,
364+
};
365+
332366
const { allRowsSelected, clearSelections, handleRowSelect, handleSelectAll, selectedRows } =
333367
useRowSelection({
334368
data: data?.task_instances,
@@ -354,13 +388,13 @@ export const TaskInstances = () => {
354388
<TaskInstancesFilter />
355389
<DataTable
356390
columns={columns}
391+
cursorPagination={cursorPagination}
357392
data={data?.task_instances ?? []}
358393
errorMessage={<ErrorAlert error={error} />}
359394
initialState={tableURLState}
360395
isLoading={isLoading}
361396
modelName="common:taskInstance"
362397
onStateChange={setTableURLState}
363-
total={data && "total_entries" in data ? data.total_entries : undefined}
364398
/>
365399
<ActionBar.Root closeOnInteractOutside={false} open={Boolean(selectedRows.size)}>
366400
<ActionBar.Content>

0 commit comments

Comments
 (0)