Skip to content

Commit db2e117

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 57d3df2 commit db2e117

3 files changed

Lines changed: 75 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: 33 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 { 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,17 @@ export const TaskInstances = () => {
274276
const [sort] = sorting;
275277
const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : ["-id"];
276278

279+
// Reset cursor when filters or sort change. URL search params capture both,
280+
// so comparing their serialized form detects any change without useEffect.
281+
const [cursor, setCursor] = useState<string>("");
282+
const searchKey = searchParams.toString();
283+
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
284+
285+
if (searchKey !== prevSearchKey) {
286+
setCursor("");
287+
setPrevSearchKey(searchKey);
288+
}
289+
277290
const filteredState = searchParams.getAll(STATE_PARAM);
278291
const filteredDagVersion = searchParams.get(DAG_VERSION_PARAM);
279292
const durationGte = searchParams.get(DURATION_GTE_PARAM);
@@ -296,6 +309,7 @@ export const TaskInstances = () => {
296309

297310
const { data, error, isLoading } = useTaskInstanceServiceGetTaskInstances(
298311
{
312+
cursor,
299313
dagId: dagId ?? "~",
300314
dagIdPattern: filteredDagIdPattern ?? undefined,
301315
dagRunId: runId ?? "~",
@@ -306,7 +320,6 @@ export const TaskInstances = () => {
306320
logicalDateGte: logicalDateGte ?? undefined,
307321
logicalDateLte: logicalDateLte ?? undefined,
308322
mapIndex: mapIndexFilter !== null && mapIndexFilter !== "" ? [Number(mapIndexFilter)] : undefined,
309-
offset: pagination.pageIndex * pagination.pageSize,
310323
operatorNamePattern: operatorNamePattern ?? undefined,
311324
orderBy,
312325
poolNamePattern: poolNamePattern ?? undefined,
@@ -329,6 +342,24 @@ export const TaskInstances = () => {
329342
},
330343
);
331344

345+
const nextCursor = data && "next_cursor" in data ? data.next_cursor : undefined;
346+
const previousCursor = data && "previous_cursor" in data ? data.previous_cursor : undefined;
347+
348+
const cursorPagination: CursorPaginationProps = {
349+
hasNext: nextCursor !== undefined && nextCursor !== null,
350+
hasPrevious: previousCursor !== undefined && previousCursor !== null,
351+
onNext: () => {
352+
if (nextCursor !== undefined && nextCursor !== null) {
353+
setCursor(nextCursor);
354+
}
355+
},
356+
onPrevious: () => {
357+
if (previousCursor !== undefined && previousCursor !== null) {
358+
setCursor(previousCursor);
359+
}
360+
},
361+
};
362+
332363
const { allRowsSelected, clearSelections, handleRowSelect, handleSelectAll, selectedRows } =
333364
useRowSelection({
334365
data: data?.task_instances,
@@ -354,13 +385,13 @@ export const TaskInstances = () => {
354385
<TaskInstancesFilter />
355386
<DataTable
356387
columns={columns}
388+
cursorPagination={cursorPagination}
357389
data={data?.task_instances ?? []}
358390
errorMessage={<ErrorAlert error={error} />}
359391
initialState={tableURLState}
360392
isLoading={isLoading}
361393
modelName="common:taskInstance"
362394
onStateChange={setTableURLState}
363-
total={data?.total_entries ?? undefined}
364395
/>
365396
<ActionBar.Root closeOnInteractOutside={false} open={Boolean(selectedRows.size)}>
366397
<ActionBar.Content>

0 commit comments

Comments
 (0)