diff --git a/airflow/www/jest-setup.js b/airflow/www/jest-setup.js index f5a269cfa2a5d..ecf79db5cb19f 100644 --- a/airflow/www/jest-setup.js +++ b/airflow/www/jest-setup.js @@ -67,3 +67,5 @@ global.filtersOptions = { global.moment = moment; global.standaloneDagProcessor = true; + +global.autoRefreshInterval = undefined; diff --git a/airflow/www/static/js/api/useTaskInstance.ts b/airflow/www/static/js/api/useTaskInstance.ts index 84d44ca02dc07..8e7c4faf32425 100644 --- a/airflow/www/static/js/api/useTaskInstance.ts +++ b/airflow/www/static/js/api/useTaskInstance.ts @@ -19,7 +19,7 @@ import axios, { AxiosResponse } from "axios"; import type { API } from "src/types"; -import { useQuery } from "react-query"; +import { useQuery, UseQueryOptions } from "react-query"; import { useAutoRefresh } from "src/context/autorefresh"; import { getMetaValue } from "src/utils"; @@ -29,7 +29,7 @@ const taskInstanceApi = getMetaValue("task_instance_api"); interface Props extends SetOptional { - enabled?: boolean; + options?: UseQueryOptions; } const useTaskInstance = ({ @@ -37,13 +37,14 @@ const useTaskInstance = ({ dagRunId, taskId, mapIndex, - enabled, + options, }: Props) => { let url: string = ""; if (taskInstanceApi) { url = taskInstanceApi + .replace("_DAG_ID_", dagId) .replace("_DAG_RUN_ID_", dagRunId) - .replace("_TASK_ID_", taskId || ""); + .replace("_TASK_ID_", taskId); } if (mapIndex !== undefined && mapIndex >= 0) { @@ -52,12 +53,12 @@ const useTaskInstance = ({ const { isRefreshOn } = useAutoRefresh(); - return useQuery( + return useQuery( ["taskInstance", dagId, dagRunId, taskId, mapIndex], () => axios.get(url), { refetchInterval: isRefreshOn && (autoRefreshInterval || 1) * 1000, - enabled, + ...options, } ); }; diff --git a/airflow/www/static/js/dag/details/BreadcrumbText.tsx b/airflow/www/static/js/components/BreadcrumbText.tsx similarity index 100% rename from airflow/www/static/js/dag/details/BreadcrumbText.tsx rename to airflow/www/static/js/components/BreadcrumbText.tsx diff --git a/airflow/www/static/js/components/DatasetEventCard.tsx b/airflow/www/static/js/components/DatasetEventCard.tsx new file mode 100644 index 0000000000000..e6c5b6bb9cbad --- /dev/null +++ b/airflow/www/static/js/components/DatasetEventCard.tsx @@ -0,0 +1,183 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from "react"; +import { isEmpty } from "lodash"; +import { TbApi } from "react-icons/tb"; + +import type { DatasetEvent } from "src/types/api-generated"; +import { + Box, + Flex, + Tooltip, + Text, + Grid, + GridItem, + Link, +} from "@chakra-ui/react"; +import { HiDatabase } from "react-icons/hi"; +import { FiLink } from "react-icons/fi"; +import { useSearchParams } from "react-router-dom"; + +import { getMetaValue } from "src/utils"; +import Time from "src/components/Time"; +import { useContainerRef } from "src/context/containerRef"; +import { SimpleStatus } from "src/dag/StatusBox"; +import { formatDuration, getDuration } from "src/datetime_utils"; +import RenderedJsonField from "src/components/RenderedJsonField"; + +import SourceTaskInstance from "./SourceTaskInstance"; + +type CardProps = { + datasetEvent: DatasetEvent; +}; + +const gridUrl = getMetaValue("grid_url"); +const datasetsUrl = getMetaValue("datasets_url"); + +const DatasetEventCard = ({ datasetEvent }: CardProps) => { + const [searchParams] = useSearchParams(); + + const selectedUri = decodeURIComponent(searchParams.get("uri") || ""); + const containerRef = useContainerRef(); + + const { fromRestApi, ...extra } = datasetEvent?.extra as Record< + string, + string + >; + + return ( + + + + + + Source: + {fromRestApi && ( + + + + + + )} + {!!datasetEvent.sourceTaskId && ( + + )} + + + {!!datasetEvent?.createdDagruns?.length && ( + <> + Triggered Dag Runs: + + {datasetEvent?.createdDagruns.map((run) => { + const runId = (run as any).dagRunId; // For some reason the type is wrong here + const url = `${gridUrl?.replace( + "__DAG_ID__", + run.dagId || "" + )}?dag_run_id=${encodeURIComponent(runId)}`; + + return ( + + DAG Id: {run.dagId} + Status: {run.state || "no status"} + + Duration:{" "} + {formatDuration( + getDuration(run.startDate, run.endDate) + )} + + + Start Date: + {run.endDate && ( + + End Date: + )} + + } + portalProps={{ containerRef }} + hasArrow + placement="top" + > + + + + + + + + ); + })} + + + )} + + + {!isEmpty(extra) && ( + + )} + + ); +}; + +export default DatasetEventCard; diff --git a/airflow/www/static/js/dag/InstanceTooltip.test.tsx b/airflow/www/static/js/components/InstanceTooltip.test.tsx similarity index 100% rename from airflow/www/static/js/dag/InstanceTooltip.test.tsx rename to airflow/www/static/js/components/InstanceTooltip.test.tsx diff --git a/airflow/www/static/js/dag/InstanceTooltip.tsx b/airflow/www/static/js/components/InstanceTooltip.tsx similarity index 70% rename from airflow/www/static/js/dag/InstanceTooltip.tsx rename to airflow/www/static/js/components/InstanceTooltip.tsx index cfdd0be13ec83..f7d83f347c022 100644 --- a/airflow/www/static/js/dag/InstanceTooltip.tsx +++ b/airflow/www/static/js/components/InstanceTooltip.tsx @@ -26,9 +26,22 @@ import { formatDuration, getDuration } from "src/datetime_utils"; import type { TaskInstance, Task } from "src/types"; import Time from "src/components/Time"; +type Instance = Pick< + TaskInstance, + | "taskId" + | "startDate" + | "endDate" + | "state" + | "runId" + | "mappedStates" + | "note" + | "tryNumber" +>; + interface Props { - group: Task; - instance: TaskInstance; + group?: Task; + instance: Instance; + dagId?: string; } const InstanceTooltip = ({ @@ -43,38 +56,43 @@ const InstanceTooltip = ({ note, tryNumber, }, + dagId, }: Props) => { - if (!group) return null; - const isGroup = !!group.children; - const { isMapped } = group; + const isGroup = !!group?.children; + const isMapped = !!group?.isMapped; const summary: React.ReactNode[] = []; - const { totalTasks, childTaskMap } = getGroupAndMapSummary({ - group, - runId, - mappedStates, - }); + let totalTasks = 1; + if (group) { + const { totalTasks: total, childTaskMap } = getGroupAndMapSummary({ + group, + runId, + mappedStates, + }); + totalTasks = total; - childTaskMap.forEach((key, val) => { - const childState = snakeCase(val); - if (key > 0) { - summary.push( - - {childState} - {": "} - {key} - - ); - } - }); + childTaskMap.forEach((key, val) => { + const childState = snakeCase(val); + if (key > 0) { + summary.push( + + {childState} + {": "} + {key} + + ); + } + }); + } return ( + {!!dagId && DAG Id: {dagId}} Task Id: {taskId} - {!!group.setupTeardownType && ( + {!!group?.setupTeardownType && ( Type: {group.setupTeardownType} )} - {group.tooltip && {group.tooltip}} + {group?.tooltip && {group.tooltip}} {isMapped && totalTasks > 0 && ( {totalTasks} mapped task @@ -103,7 +121,7 @@ const InstanceTooltip = ({ )} {tryNumber && tryNumber > 1 && Try Number: {tryNumber}} - {group.triggerRule && Trigger Rule: {group.triggerRule}} + {group?.triggerRule && Trigger Rule: {group.triggerRule}} {note && Contains a note} ); diff --git a/airflow/www/static/js/components/RenderedJsonField.tsx b/airflow/www/static/js/components/RenderedJsonField.tsx index a5e216f64d127..7000dc17ad3c5 100644 --- a/airflow/www/static/js/components/RenderedJsonField.tsx +++ b/airflow/www/static/js/components/RenderedJsonField.tsx @@ -19,7 +19,7 @@ import React from "react"; -import ReactJson from "react-json-view"; +import ReactJson, { ReactJsonViewProps } from "react-json-view"; import { Flex, @@ -32,15 +32,20 @@ import { } from "@chakra-ui/react"; interface Props extends FlexProps { - content: string; + content: string | object; + jsonProps?: Omit; } -const JsonParse = (content: string) => { +const JsonParse = (content: string | object) => { let contentJson = null; let contentFormatted = ""; let isJson = false; try { - contentJson = JSON.parse(content); + if (typeof content === "string") { + contentJson = JSON.parse(content); + } else { + contentJson = content; + } contentFormatted = JSON.stringify(contentJson, null, 4); isJson = true; } catch (e) { @@ -49,7 +54,7 @@ const JsonParse = (content: string) => { return [isJson, contentJson, contentFormatted]; }; -const RenderedJsonField = ({ content, ...rest }: Props) => { +const RenderedJsonField = ({ content, jsonProps, ...rest }: Props) => { const [isJson, contentJson, contentFormatted] = JsonParse(content); const { onCopy, hasCopied } = useClipboard(contentFormatted); const theme = useTheme(); @@ -69,14 +74,15 @@ const RenderedJsonField = ({ content, ...rest }: Props) => { fontSize: theme.fontSizes.md, font: theme.fonts.mono, }} + {...jsonProps} /> - ) : ( - {content} + {content as string} ); }; diff --git a/airflow/www/static/js/components/SourceTaskInstance.tsx b/airflow/www/static/js/components/SourceTaskInstance.tsx new file mode 100644 index 0000000000000..3dcae30d32501 --- /dev/null +++ b/airflow/www/static/js/components/SourceTaskInstance.tsx @@ -0,0 +1,95 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from "react"; +import { Box, Link, Tooltip, Flex } from "@chakra-ui/react"; +import { FiLink } from "react-icons/fi"; + +import { useTaskInstance } from "src/api"; +import type { DatasetEvent } from "src/types/api-generated"; +import { useContainerRef } from "src/context/containerRef"; +import { SimpleStatus } from "src/dag/StatusBox"; +import InstanceTooltip from "src/components/InstanceTooltip"; +import type { TaskInstance } from "src/types"; +import { getMetaValue } from "src/utils"; + +type SourceTIProps = { + datasetEvent: DatasetEvent; +}; + +const gridUrl = getMetaValue("grid_url"); + +const SourceTaskInstance = ({ datasetEvent }: SourceTIProps) => { + const containerRef = useContainerRef(); + const { sourceDagId, sourceRunId, sourceTaskId, sourceMapIndex } = + datasetEvent; + + const { data: taskInstance } = useTaskInstance({ + dagId: sourceDagId || "", + dagRunId: sourceRunId || "", + taskId: sourceTaskId || "", + mapIndex: sourceMapIndex || undefined, + options: { + enabled: !!(sourceDagId && sourceRunId && sourceTaskId), + refetchInterval: false, + }, + }); + + let url = `${gridUrl?.replace( + "__DAG_ID__", + sourceDagId || "" + )}?dag_run_id=${encodeURIComponent( + sourceRunId || "" + )}&task_id=${encodeURIComponent(sourceTaskId || "")}`; + + if ( + sourceMapIndex !== null && + sourceMapIndex !== undefined && + sourceMapIndex > -1 + ) { + url = `${url}&map_index=${sourceMapIndex}`; + } + + return ( + + {!!taskInstance && ( + + } + portalProps={{ containerRef }} + hasArrow + placement="top" + > + + + + + + + + )} + + ); +}; + +export default SourceTaskInstance; diff --git a/airflow/www/static/js/components/Table/CardList.tsx b/airflow/www/static/js/components/Table/CardList.tsx new file mode 100644 index 0000000000000..668566737d616 --- /dev/null +++ b/airflow/www/static/js/components/Table/CardList.tsx @@ -0,0 +1,223 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * Custom wrapper of react-table using Chakra UI components + */ + +import React, { useEffect } from "react"; +import { + Flex, + IconButton, + Text, + SimpleGrid, + Box, + Progress, + Skeleton, + BoxProps, + SimpleGridProps, +} from "@chakra-ui/react"; +import { + useTable, + useSortBy, + usePagination, + useRowSelect, + Column, + SortingRule, + Row, +} from "react-table"; +import { MdKeyboardArrowLeft, MdKeyboardArrowRight } from "react-icons/md"; +import { flexRender } from "@tanstack/react-table"; + +export interface CardDef { + card: (props: { row: TData }) => any; + gridProps?: SimpleGridProps; + meta?: { + customSkeleton?: JSX.Element; + }; +} + +interface TableProps extends BoxProps { + data: object[]; + columns: Column[]; + manualPagination?: { + totalEntries: number; + offset: number; + setOffset: (offset: number) => void; + }; + manualSort?: { + sortBy: SortingRule[]; + setSortBy: (sortBy: SortingRule[]) => void; + initialSortBy?: SortingRule[]; + }; + pageSize?: number; + isLoading?: boolean; + selectRows?: (selectedRows: number[]) => void; + onRowClicked?: (row: Row, e: unknown) => void; + cardDef: CardDef; +} + +export const CardList = ({ + data, + cardDef, + columns, + manualPagination, + manualSort, + pageSize = 25, + isLoading = false, + selectRows, + onRowClicked, + ...otherProps +}: TableProps) => { + const { totalEntries, offset, setOffset } = manualPagination || {}; + + const pageCount = totalEntries + ? Math.ceil(totalEntries / pageSize) || 1 + : data.length; + + const lowerCount = (offset || 0) + 1; + const upperCount = lowerCount + data.length - 1; + + // Don't show row selection if selectRows doesn't exist + const selectProps = selectRows ? [useRowSelect] : []; + + const { + prepareRow, + page, + canPreviousPage, + canNextPage, + nextPage, + previousPage, + selectedFlatRows, + state: { pageIndex, sortBy, selectedRowIds }, + } = useTable( + { + columns, + data, + pageCount, + manualPagination: !!manualPagination, + manualSortBy: !!manualSort, + disableMultiSort: !!manualSort, // API only supporting ordering by a single column + initialState: { + pageIndex: offset ? offset / pageSize : 0, + pageSize, + sortBy: manualSort?.initialSortBy || [], + }, + }, + useSortBy, + usePagination, + ...selectProps + ); + + const handleNext = () => { + nextPage(); + if (setOffset) setOffset((pageIndex + 1) * pageSize); + }; + + const handlePrevious = () => { + previousPage(); + if (setOffset) setOffset((pageIndex - 1 || 0) * pageSize); + }; + + // When the sortBy state changes we need to manually call setSortBy + useEffect(() => { + if (manualSort) { + manualSort.setSortBy(sortBy); + } + }, [sortBy, manualSort]); + + useEffect(() => { + if (selectRows) { + // @ts-ignore + selectRows(selectedFlatRows.map((row) => row.original.mapIndex)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedRowIds, selectRows]); + + const defaultGridProps = { column: { base: 1 }, spacing: 0 }; + + return ( + <> + + + {!isLoading && !page.length && ( + No data found + )} + + {page.map((row) => { + prepareRow(row); + return ( + onRowClicked(row, e) + : undefined + } + > + {isLoading && ( + + )} + {!isLoading && + flexRender(cardDef.card, { + row: row.original as unknown as TData, + })} + + ); + })} + + + {(canPreviousPage || canNextPage) && ( + + } + /> + } + /> + + {lowerCount}-{upperCount} + {" of "} + {totalEntries || data.length} + + + )} + + ); +}; diff --git a/airflow/www/static/js/components/Table/Cells.test.tsx b/airflow/www/static/js/components/Table/Cells.test.tsx deleted file mode 100644 index ac1273b3e10c7..0000000000000 --- a/airflow/www/static/js/components/Table/Cells.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/*! - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/* global describe, test, expect */ - -import React from "react"; -import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; - -import { ChakraWrapper } from "src/utils/testUtils"; -import * as utils from "src/utils"; -import { TaskInstanceLink } from "./Cells"; - -const taskId = "task_id"; -const sourceDagId = "source_dag_id"; -const sourceRunId = "source_run_id"; -const originalDagId = "og_dag_id"; - -describe("Test TaskInstanceLink", () => { - test("Replaces __DAG_ID__ url param correctly", async () => { - jest.spyOn(utils, "getMetaValue").mockImplementation((meta) => { - if (meta === "grid_url") return "/dags/__DAG_ID__/grid"; - return ""; - }); - - const { getByText } = render( - , - { wrapper: ChakraWrapper } - ); - - const link = getByText(`${sourceDagId}.${taskId}`); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute( - "href", - `/dags/${sourceDagId}/grid?dag_run_id=${sourceRunId}&task_id=${taskId}` - ); - }); - - test("Replaces existing dag id url param correctly", async () => { - jest.spyOn(utils, "getMetaValue").mockImplementation((meta) => { - if (meta === "dag_id") return originalDagId; - if (meta === "grid_url") return `/dags/${originalDagId}/grid`; - return ""; - }); - - const { getByText } = render( - , - { wrapper: ChakraWrapper } - ); - - const link = getByText(`${sourceDagId}.${taskId}`); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute( - "href", - `/dags/${sourceDagId}/grid?dag_run_id=${sourceRunId}&task_id=${taskId}` - ); - }); -}); diff --git a/airflow/www/static/js/components/Table/Cells.tsx b/airflow/www/static/js/components/Table/Cells.tsx index 9f75e0e484503..ec39d89087341 100644 --- a/airflow/www/static/js/components/Table/Cells.tsx +++ b/airflow/www/static/js/components/Table/Cells.tsx @@ -17,27 +17,9 @@ * under the License. */ -import React, { useMemo } from "react"; -import { - Flex, - Code, - Link, - Box, - Text, - useDisclosure, - ModalCloseButton, - Modal, - ModalContent, - ModalOverlay, - ModalBody, - ModalHeader, -} from "@chakra-ui/react"; +import React from "react"; -import { Table } from "src/components/Table"; import Time from "src/components/Time"; -import { getMetaValue } from "src/utils"; -import { useContainerRef } from "src/context/containerRef"; -import { SimpleStatus } from "src/dag/StatusBox"; export interface CellProps { cell: { @@ -53,124 +35,3 @@ export interface CellProps { export const TimeCell = ({ cell: { value } }: CellProps) => (