This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 25e1cf1477 Datasets UI Improvements (#40871)
25e1cf1477 is described below
commit 25e1cf147792cf75e9d1ba169cd057aef4c8e6d6
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Jul 24 08:16:16 2024 -0400
Datasets UI Improvements (#40871)
* Update Datasets page UX
* Cleanup selected dataset event code
* Use DatasetEventCard everywhere, use RenderedJson for extra, add links
---
airflow/www/jest-setup.js | 2 +
airflow/www/static/js/api/useTaskInstance.ts | 13 +-
.../{dag/details => components}/BreadcrumbText.tsx | 0
.../www/static/js/components/DatasetEventCard.tsx | 183 +++++++++++++
.../{dag => components}/InstanceTooltip.test.tsx | 0
.../js/{dag => components}/InstanceTooltip.tsx | 68 +++--
.../www/static/js/components/RenderedJsonField.tsx | 20 +-
.../static/js/components/SourceTaskInstance.tsx | 95 +++++++
.../components/Table/{index.tsx => CardList.tsx} | 174 ++++--------
.../www/static/js/components/Table/Cells.test.tsx | 96 -------
airflow/www/static/js/components/Table/Cells.tsx | 141 +---------
airflow/www/static/js/components/Table/index.tsx | 1 +
airflow/www/static/js/context/autorefresh.tsx | 19 +-
airflow/www/static/js/dag/StatusBox.tsx | 2 +-
airflow/www/static/js/dag/details/Header.tsx | 3 +-
.../js/dag/details/dagRun/DatasetTriggerEvents.tsx | 36 +--
.../www/static/js/dag/details/graph/DagNode.tsx | 2 +-
airflow/www/static/js/dag/details/index.tsx | 4 +-
.../details/taskInstance/DatasetUpdateEvents.tsx | 36 +--
.../dag/details/taskInstance/MappedInstances.tsx | 10 +-
.../static/js/dag/details/taskInstance/index.tsx | 4 +-
airflow/www/static/js/datasets/DatasetDetails.tsx | 142 ++++++++++
airflow/www/static/js/datasets/DatasetEvents.tsx | 95 ++++---
.../{List.test.tsx => DatasetsList.test.tsx} | 6 +-
.../js/datasets/{List.tsx => DatasetsList.tsx} | 57 +---
airflow/www/static/js/datasets/Details.tsx | 97 -------
airflow/www/static/js/datasets/Graph/Node.tsx | 4 +-
airflow/www/static/js/datasets/Graph/index.tsx | 15 +-
airflow/www/static/js/datasets/Main.tsx | 301 ++++++++++++++-------
airflow/www/static/js/datasets/SearchBar.tsx | 6 +-
.../BreadcrumbText.tsx => datasets/types.ts} | 32 +--
airflow/www/templates/airflow/dag.html | 2 +-
airflow/www/templates/airflow/datasets.html | 2 +
airflow/www/views.py | 1 +
34 files changed, 910 insertions(+), 759 deletions(-)
diff --git a/airflow/www/jest-setup.js b/airflow/www/jest-setup.js
index f5a269cfa2..ecf79db5cb 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 84d44ca02d..8e7c4faf32 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<API.GetMappedTaskInstanceVariables, "mapIndex"> {
- enabled?: boolean;
+ options?: UseQueryOptions<API.TaskInstance>;
}
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<API.TaskInstance>(
["taskInstance", dagId, dagRunId, taskId, mapIndex],
() => axios.get<AxiosResponse, API.TaskInstance>(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%
copy from airflow/www/static/js/dag/details/BreadcrumbText.tsx
copy 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 0000000000..e6c5b6bb9c
--- /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 (
+ <Box>
+ <Grid
+ templateColumns="repeat(4, 1fr)"
+ key={`${datasetEvent.datasetId}-${datasetEvent.timestamp}`}
+ _hover={{ bg: "gray.50" }}
+ transition="background-color 0.2s"
+ p={2}
+ borderTopWidth={1}
+ borderColor="gray.300"
+ borderStyle="solid"
+ >
+ <GridItem colSpan={2}>
+ <Time dateTime={datasetEvent.timestamp} />
+ <Flex alignItems="center">
+ <HiDatabase size="16px" />
+ {datasetEvent.datasetUri &&
+ datasetEvent.datasetUri !== selectedUri ? (
+ <Link
+ color="blue.600"
+ ml={2}
+ href={`${datasetsUrl}?uri=${encodeURIComponent(
+ datasetEvent.datasetUri
+ )}`}
+ >
+ {datasetEvent.datasetUri}
+ </Link>
+ ) : (
+ <Text ml={2}>{datasetEvent.datasetUri}</Text>
+ )}
+ </Flex>
+ </GridItem>
+ <GridItem>
+ Source:
+ {fromRestApi && (
+ <Tooltip
+ portalProps={{ containerRef }}
+ hasArrow
+ placement="top"
+ label="Manually created from REST API"
+ >
+ <Box width="20px">
+ <TbApi size="20px" />
+ </Box>
+ </Tooltip>
+ )}
+ {!!datasetEvent.sourceTaskId && (
+ <SourceTaskInstance datasetEvent={datasetEvent} />
+ )}
+ </GridItem>
+ <GridItem>
+ {!!datasetEvent?.createdDagruns?.length && (
+ <>
+ Triggered Dag Runs:
+ <Flex alignItems="center">
+ {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 (
+ <Tooltip
+ key={runId}
+ label={
+ <Box>
+ <Text>DAG Id: {run.dagId}</Text>
+ <Text>Status: {run.state || "no status"}</Text>
+ <Text>
+ Duration:{" "}
+ {formatDuration(
+ getDuration(run.startDate, run.endDate)
+ )}
+ </Text>
+ <Text>
+ Start Date: <Time dateTime={run.startDate} />
+ </Text>
+ {run.endDate && (
+ <Text>
+ End Date: <Time dateTime={run.endDate} />
+ </Text>
+ )}
+ </Box>
+ }
+ portalProps={{ containerRef }}
+ hasArrow
+ placement="top"
+ >
+ <Flex width="30px">
+ <SimpleStatus state={run.state} mx={1} />
+ <Link color="blue.600" href={url}>
+ <FiLink size="12px" />
+ </Link>
+ </Flex>
+ </Tooltip>
+ );
+ })}
+ </Flex>
+ </>
+ )}
+ </GridItem>
+ </Grid>
+ {!isEmpty(extra) && (
+ <RenderedJsonField
+ content={extra}
+ bg="gray.100"
+ maxH="300px"
+ overflow="auto"
+ jsonProps={{
+ collapsed: true,
+ }}
+ />
+ )}
+ </Box>
+ );
+};
+
+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 cfdd0be13e..f7d83f347c 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(
- <Text key={childState} ml="10px">
- {childState}
- {": "}
- {key}
- </Text>
- );
- }
- });
+ childTaskMap.forEach((key, val) => {
+ const childState = snakeCase(val);
+ if (key > 0) {
+ summary.push(
+ <Text key={childState} ml="10px">
+ {childState}
+ {": "}
+ {key}
+ </Text>
+ );
+ }
+ });
+ }
return (
<Box py="2px">
+ {!!dagId && <Text>DAG Id: {dagId}</Text>}
<Text>Task Id: {taskId}</Text>
- {!!group.setupTeardownType && (
+ {!!group?.setupTeardownType && (
<Text>Type: {group.setupTeardownType}</Text>
)}
- {group.tooltip && <Text>{group.tooltip}</Text>}
+ {group?.tooltip && <Text>{group.tooltip}</Text>}
{isMapped && totalTasks > 0 && (
<Text>
{totalTasks} mapped task
@@ -103,7 +121,7 @@ const InstanceTooltip = ({
</>
)}
{tryNumber && tryNumber > 1 && <Text>Try Number: {tryNumber}</Text>}
- {group.triggerRule && <Text>Trigger Rule: {group.triggerRule}</Text>}
+ {group?.triggerRule && <Text>Trigger Rule: {group.triggerRule}</Text>}
{note && <Text>Contains a note</Text>}
</Box>
);
diff --git a/airflow/www/static/js/components/RenderedJsonField.tsx
b/airflow/www/static/js/components/RenderedJsonField.tsx
index a5e216f64d..7000dc17ad 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<ReactJsonViewProps, "src">;
}
-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}
/>
<Spacer />
- <Button aria-label="Copy" onClick={onCopy}>
+ <Button aria-label="Copy" onClick={onCopy} position="sticky" top={0}>
{hasCopied ? "Copied!" : "Copy"}
</Button>
</Flex>
) : (
- <Code fontSize="md">{content}</Code>
+ <Code fontSize="md">{content as string}</Code>
);
};
diff --git a/airflow/www/static/js/components/SourceTaskInstance.tsx
b/airflow/www/static/js/components/SourceTaskInstance.tsx
new file mode 100644
index 0000000000..3dcae30d32
--- /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 (
+ <Box>
+ {!!taskInstance && (
+ <Tooltip
+ label={
+ <InstanceTooltip
+ instance={{ ...taskInstance, runId: sourceRunId } as
TaskInstance}
+ dagId={sourceDagId || undefined}
+ />
+ }
+ portalProps={{ containerRef }}
+ hasArrow
+ placement="top"
+ >
+ <Flex width="30px">
+ <SimpleStatus state={taskInstance.state} mx={1} />
+ <Link color="blue.600" href={url}>
+ <FiLink size="12px" />
+ </Link>
+ </Flex>
+ </Tooltip>
+ )}
+ </Box>
+ );
+};
+
+export default SourceTaskInstance;
diff --git a/airflow/www/static/js/components/Table/index.tsx
b/airflow/www/static/js/components/Table/CardList.tsx
similarity index 54%
copy from airflow/www/static/js/components/Table/index.tsx
copy to airflow/www/static/js/components/Table/CardList.tsx
index 753035da07..668566737d 100644
--- a/airflow/www/static/js/components/Table/index.tsx
+++ b/airflow/www/static/js/components/Table/CardList.tsx
@@ -21,20 +21,17 @@
* Custom wrapper of react-table using Chakra UI components
*/
-import React, { useEffect, useRef, forwardRef, RefObject } from "react";
+import React, { useEffect } from "react";
import {
Flex,
- Table as ChakraTable,
- Thead,
- Tbody,
- Tr,
- Th,
- Td,
IconButton,
Text,
- useColorModeValue,
- Checkbox,
- CheckboxProps,
+ SimpleGrid,
+ Box,
+ Progress,
+ Skeleton,
+ BoxProps,
+ SimpleGridProps,
} from "@chakra-ui/react";
import {
useTable,
@@ -42,38 +39,21 @@ import {
usePagination,
useRowSelect,
Column,
- Hooks,
SortingRule,
Row,
} from "react-table";
import { MdKeyboardArrowLeft, MdKeyboardArrowRight } from "react-icons/md";
-import {
- TiArrowUnsorted,
- TiArrowSortedDown,
- TiArrowSortedUp,
-} from "react-icons/ti";
+import { flexRender } from "@tanstack/react-table";
-interface IndeterminateCheckboxProps extends CheckboxProps {
- indeterminate?: boolean;
+export interface CardDef<TData> {
+ card: (props: { row: TData }) => any;
+ gridProps?: SimpleGridProps;
+ meta?: {
+ customSkeleton?: JSX.Element;
+ };
}
-const IndeterminateCheckbox = forwardRef<
- HTMLInputElement,
- IndeterminateCheckboxProps
->(({ indeterminate, checked, ...rest }, ref) => {
- const defaultRef = useRef<HTMLInputElement>(null);
- const resolvedRef = (ref as RefObject<HTMLInputElement>) || defaultRef;
-
- useEffect(() => {
- if (resolvedRef.current) {
- resolvedRef.current.indeterminate = !!indeterminate;
- }
- }, [resolvedRef, indeterminate]);
-
- return <Checkbox ref={resolvedRef} isChecked={checked} {...rest} />;
-});
-
-interface TableProps {
+interface TableProps<TData> extends BoxProps {
data: object[];
columns: Column<object>[];
manualPagination?: {
@@ -90,10 +70,12 @@ interface TableProps {
isLoading?: boolean;
selectRows?: (selectedRows: number[]) => void;
onRowClicked?: (row: Row<object>, e: unknown) => void;
+ cardDef: CardDef<TData>;
}
-export const Table = ({
+export const CardList = <TData extends any>({
data,
+ cardDef,
columns,
manualPagination,
manualSort,
@@ -101,10 +83,9 @@ export const Table = ({
isLoading = false,
selectRows,
onRowClicked,
-}: TableProps) => {
+ ...otherProps
+}: TableProps<TData>) => {
const { totalEntries, offset, setOffset } = manualPagination || {};
- const oddColor = useColorModeValue("gray.50", "gray.900");
- const hoverColor = useColorModeValue("gray.100", "gray.700");
const pageCount = totalEntries
? Math.ceil(totalEntries / pageSize) || 1
@@ -114,30 +95,9 @@ export const Table = ({
const upperCount = lowerCount + data.length - 1;
// Don't show row selection if selectRows doesn't exist
- const selectProps = selectRows
- ? [
- useRowSelect,
- (hooks: Hooks) => {
- hooks.visibleColumns.push((cols) => [
- {
- id: "selection",
- // eslint-disable-next-line react/no-unstable-nested-components
- Cell: ({ row }) => (
- <div>
- <IndeterminateCheckbox {...row.getToggleRowSelectedProps()}
/>
- </div>
- ),
- },
- ...cols,
- ]);
- },
- ]
- : [];
+ const selectProps = selectRows ? [useRowSelect] : [];
const {
- getTableProps,
- getTableBodyProps,
- allColumns,
prepareRow,
page,
canPreviousPage,
@@ -190,75 +150,49 @@ export const Table = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRowIds, selectRows]);
+ const defaultGridProps = { column: { base: 1 }, spacing: 0 };
+
return (
<>
- <ChakraTable {...getTableProps()}>
- <Thead>
- <Tr>
- {allColumns.map((column) => (
- <Th {...column.getHeaderProps(column.getSortByToggleProps())}>
- <Flex>
- {column.render("Header")}
- {column.isSorted &&
- (column.isSortedDesc ? (
- <TiArrowSortedDown
- aria-label="sorted descending"
- style={{ display: "inline" }}
- size="1em"
- />
- ) : (
- <TiArrowSortedUp
- aria-label="sorted ascending"
- style={{ display: "inline" }}
- size="1em"
- />
- ))}
- {!column.isSorted && column.canSort && (
- <TiArrowUnsorted
- aria-label="unsorted"
- style={{ display: "inline" }}
- size="1em"
- />
- )}
- </Flex>
- </Th>
- ))}
- </Tr>
- </Thead>
- <Tbody {...getTableBodyProps()}>
- {!data.length && !isLoading && (
- <Tr>
- <Td colSpan={2}>No Data found.</Td>
- </Tr>
- )}
+ <Box overflow="auto" width="100%" {...otherProps}>
+ <Progress
+ size="xs"
+ isIndeterminate
+ visibility={isLoading ? "visible" : "hidden"}
+ />
+ {!isLoading && !page.length && (
+ <Text fontSize="small">No data found</Text>
+ )}
+ <SimpleGrid {...defaultGridProps}>
{page.map((row) => {
prepareRow(row);
return (
- <Tr
- {...row.getRowProps()}
- _odd={{ backgroundColor: oddColor }}
- _hover={
- onRowClicked && {
- backgroundColor: hoverColor,
- cursor: "pointer",
- }
- }
+ <Box
+ key={row.id}
+ _hover={onRowClicked && { cursor: "pointer" }}
onClick={
- onRowClicked
- ? (e: unknown) => onRowClicked(row, e)
+ onRowClicked && !isLoading
+ ? (e) => onRowClicked(row, e)
: undefined
}
>
- {row.cells.map((cell) => (
- <Td {...cell.getCellProps()} py={3}>
- {cell.render("Cell")}
- </Td>
- ))}
- </Tr>
+ {isLoading && (
+ <Skeleton
+ data-testid="skeleton"
+ height={80}
+ width="100%"
+ display="inline-block"
+ />
+ )}
+ {!isLoading &&
+ flexRender(cardDef.card, {
+ row: row.original as unknown as TData,
+ })}
+ </Box>
);
})}
- </Tbody>
- </ChakraTable>
+ </SimpleGrid>
+ </Box>
{(canPreviousPage || canNextPage) && (
<Flex alignItems="center" justifyContent="flex-start" my={4}>
<IconButton
@@ -287,5 +221,3 @@ export const Table = ({
</>
);
};
-
-export * from "./Cells";
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 ac1273b3e1..0000000000
--- 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(
- <TaskInstanceLink
- cell={{
- value: taskId,
- row: {
- original: {
- sourceRunId,
- sourceDagId,
- sourceMapIndex: -1,
- },
- },
- }}
- />,
- { 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(
- <TaskInstanceLink
- cell={{
- value: taskId,
- row: {
- original: {
- sourceRunId,
- sourceDagId,
- sourceMapIndex: -1,
- },
- },
- }}
- />,
- { 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 9f75e0e484..ec39d89087 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) => (
<Time dateTime={value} />
);
-
-export const DatasetLink = ({ cell: { value } }: CellProps) => {
- const datasetsUrl = getMetaValue("datasets_url");
- return (
- <Link
- color="blue.600"
- href={`${datasetsUrl}?uri=${encodeURIComponent(value)}`}
- >
- {value}
- </Link>
- );
-};
-
-export const DagRunLink = ({ cell: { value, row } }: CellProps) => {
- const dagId = getMetaValue("dag_id");
- const gridUrl = getMetaValue("grid_url");
- const stringToReplace = dagId || "__DAG_ID__";
- const url = `${gridUrl?.replace(
- stringToReplace,
- value
- )}?dag_run_id=${encodeURIComponent(row.original.dagRunId)}`;
- return (
- <Flex alignItems="center">
- <SimpleStatus state={row.original.state} mr={2} />
- <Link color="blue.600" href={url}>
- {value}
- </Link>
- </Flex>
- );
-};
-
-export const TriggeredRuns = ({ cell: { value, row } }: CellProps) => {
- const { isOpen, onToggle, onClose } = useDisclosure();
- const containerRef = useContainerRef();
-
- const columns = useMemo(
- () => [
- {
- Header: "DAG Id",
- accessor: "dagId",
- Cell: DagRunLink,
- },
- {
- Header: "Logical Date",
- accessor: "logicalDate",
- Cell: TimeCell,
- },
- ],
- []
- );
-
- const data = useMemo(() => value, [value]);
-
- if (!value || !value.length) return null;
-
- return (
- <Box>
- <Text color="blue.600" cursor="pointer" onClick={onToggle}>
- {value.length}
- </Text>
- <Modal
- size="3xl"
- isOpen={isOpen}
- onClose={onClose}
- scrollBehavior="inside"
- blockScrollOnMount={false}
- portalProps={{ containerRef }}
- >
- <ModalOverlay />
- <ModalContent>
- <ModalHeader>
- <Text as="span" color="gray.400">
- Dag Runs triggered by
- </Text>
- <br />
- {row.original.datasetUri}
- <br />
- <Text as="span" color="gray.400">
- at
- </Text>
- <br />
- <Time dateTime={row.original.timestamp} />
- </ModalHeader>
- <ModalCloseButton />
- <ModalBody>
- <Table data={data} columns={columns} pageSize={data.length} />
- </ModalBody>
- </ModalContent>
- </Modal>
- </Box>
- );
-};
-
-export const TaskInstanceLink = ({ cell: { value, row } }: CellProps) => {
- const { sourceRunId, sourceDagId, sourceMapIndex } = row.original;
- const gridUrl = getMetaValue("grid_url");
- const dagId = getMetaValue("dag_id");
- if (!value || !sourceRunId || !sourceDagId || !gridUrl) {
- return null;
- }
- const stringToReplace = dagId || "__DAG_ID__";
- const url = `${gridUrl?.replace(
- stringToReplace,
- sourceDagId
-
)}?dag_run_id=${encodeURIComponent(sourceRunId)}&task_id=${encodeURIComponent(
- value
- )}`;
- const mapIndex = sourceMapIndex > -1 ? `[${sourceMapIndex}]` : "";
- return (
- <Box>
- <Link
- color="blue.600"
- href={url}
- >{`${sourceDagId}.${value}${mapIndex}`}</Link>
- <Text>{sourceRunId}</Text>
- </Box>
- );
-};
-
-export const CodeCell = ({ cell: { value } }: CellProps) =>
- value ? <Code>{JSON.stringify(value)}</Code> : null;
diff --git a/airflow/www/static/js/components/Table/index.tsx
b/airflow/www/static/js/components/Table/index.tsx
index 753035da07..ea7a7786cb 100644
--- a/airflow/www/static/js/components/Table/index.tsx
+++ b/airflow/www/static/js/components/Table/index.tsx
@@ -288,4 +288,5 @@ export const Table = ({
);
};
+export * from "./CardList";
export * from "./Cells";
diff --git a/airflow/www/static/js/context/autorefresh.tsx
b/airflow/www/static/js/context/autorefresh.tsx
index b1ad380cb9..45f84068a4 100644
--- a/airflow/www/static/js/context/autorefresh.tsx
+++ b/airflow/www/static/js/context/autorefresh.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-/* global localStorage, document */
+/* global localStorage, document, autoRefreshInterval */
import React, {
useMemo,
@@ -37,9 +37,19 @@ const isRefreshDisabled = JSON.parse(
localStorage.getItem(autoRefreshKey) || "false"
);
-const AutoRefreshContext = React.createContext({
+type RefreshContext = {
+ isRefreshOn: boolean;
+ isPaused: boolean;
+ refetchInterval: number | false;
+ toggleRefresh: () => void;
+ stopRefresh: () => void;
+ startRefresh: () => void;
+};
+
+const AutoRefreshContext = React.createContext<RefreshContext>({
isRefreshOn: false,
isPaused: true,
+ refetchInterval: false,
toggleRefresh: () => {},
stopRefresh: () => {},
startRefresh: () => {},
@@ -60,6 +70,8 @@ export const AutoRefreshProvider = ({ children }:
PropsWithChildren) => {
[isRefreshAllowed, setRefresh]
);
+ const refetchInterval = isRefreshOn && (autoRefreshInterval || 1) * 1000;
+
const toggleRefresh = useCallback(
(updateStorage = false) => {
if (updateStorage) {
@@ -99,12 +111,13 @@ export const AutoRefreshProvider = ({ children }:
PropsWithChildren) => {
const value = useMemo(
() => ({
isRefreshOn,
+ refetchInterval,
toggleRefresh,
stopRefresh,
startRefresh,
isPaused,
}),
- [isPaused, isRefreshOn, startRefresh, toggleRefresh]
+ [isPaused, isRefreshOn, startRefresh, toggleRefresh, refetchInterval]
);
return (
diff --git a/airflow/www/static/js/dag/StatusBox.tsx
b/airflow/www/static/js/dag/StatusBox.tsx
index 85a84dc133..06bcb7ed7c 100644
--- a/airflow/www/static/js/dag/StatusBox.tsx
+++ b/airflow/www/static/js/dag/StatusBox.tsx
@@ -27,7 +27,7 @@ import type { SelectionProps } from "src/dag/useSelection";
import { getStatusBackgroundColor, hoverDelay } from "src/utils";
import Tooltip from "src/components/Tooltip";
-import InstanceTooltip from "./InstanceTooltip";
+import InstanceTooltip from "src/components/InstanceTooltip";
export const boxSize = 10;
export const boxSizePx = `${boxSize}px`;
diff --git a/airflow/www/static/js/dag/details/Header.tsx
b/airflow/www/static/js/dag/details/Header.tsx
index 0e1ff59ec8..172443ca47 100644
--- a/airflow/www/static/js/dag/details/Header.tsx
+++ b/airflow/www/static/js/dag/details/Header.tsx
@@ -30,8 +30,7 @@ import useSelection from "src/dag/useSelection";
import Time from "src/components/Time";
import { useGridData } from "src/api";
import RunTypeIcon from "src/components/RunTypeIcon";
-
-import BreadcrumbText from "./BreadcrumbText";
+import BreadcrumbText from "src/components/BreadcrumbText";
const dagDisplayName = getMetaValue("dag_display_name");
diff --git a/airflow/www/static/js/dag/details/dagRun/DatasetTriggerEvents.tsx
b/airflow/www/static/js/dag/details/dagRun/DatasetTriggerEvents.tsx
index 4e0ed4a956..3a3ec3eb69 100644
--- a/airflow/www/static/js/dag/details/dagRun/DatasetTriggerEvents.tsx
+++ b/airflow/www/static/js/dag/details/dagRun/DatasetTriggerEvents.tsx
@@ -19,20 +19,20 @@
import React, { useMemo } from "react";
import { Box, Text } from "@chakra-ui/react";
-import {
- CodeCell,
- DatasetLink,
- Table,
- TaskInstanceLink,
- TimeCell,
-} from "src/components/Table";
import { useUpstreamDatasetEvents } from "src/api";
import type { DagRun as DagRunType } from "src/types";
+import { CardDef, CardList } from "src/components/Table";
+import type { DatasetEvent } from "src/types/api-generated";
+import DatasetEventCard from "src/components/DatasetEventCard";
interface Props {
runId: DagRunType["runId"];
}
+const cardDef: CardDef<DatasetEvent> = {
+ card: ({ row }) => <DatasetEventCard datasetEvent={row} />,
+};
+
const DatasetTriggerEvents = ({ runId }: Props) => {
const {
data: { datasetEvents = [] },
@@ -42,25 +42,24 @@ const DatasetTriggerEvents = ({ runId }: Props) => {
const columns = useMemo(
() => [
{
- Header: "Dataset URI",
+ Header: "When",
+ accessor: "timestamp",
+ },
+ {
+ Header: "Dataset",
accessor: "datasetUri",
- Cell: DatasetLink,
},
{
Header: "Source Task Instance",
accessor: "sourceTaskId",
- Cell: TaskInstanceLink,
},
{
- Header: "When",
- accessor: "timestamp",
- Cell: TimeCell,
+ Header: "Triggered Runs",
+ accessor: "createdDagruns",
},
{
Header: "Extra",
accessor: "extra",
- Cell: CodeCell,
- disableSortBy: true,
},
],
[]
@@ -74,7 +73,12 @@ const DatasetTriggerEvents = ({ runId }: Props) => {
Dataset Events
</Text>
<Text>Dataset updates that triggered this DAG run.</Text>
- <Table data={data} columns={columns} isLoading={isLoading} />
+ <CardList
+ data={data}
+ columns={columns}
+ isLoading={isLoading}
+ cardDef={cardDef}
+ />
</Box>
);
};
diff --git a/airflow/www/static/js/dag/details/graph/DagNode.tsx
b/airflow/www/static/js/dag/details/graph/DagNode.tsx
index 7a448da880..c2f9b01296 100644
--- a/airflow/www/static/js/dag/details/graph/DagNode.tsx
+++ b/airflow/www/static/js/dag/details/graph/DagNode.tsx
@@ -25,7 +25,7 @@ import { SimpleStatus } from "src/dag/StatusBox";
import useSelection from "src/dag/useSelection";
import { getGroupAndMapSummary, hoverDelay } from "src/utils";
import Tooltip from "src/components/Tooltip";
-import InstanceTooltip from "src/dag/InstanceTooltip";
+import InstanceTooltip from "src/components/InstanceTooltip";
import { useContainerRef } from "src/context/containerRef";
import TaskName from "src/dag/TaskName";
diff --git a/airflow/www/static/js/dag/details/index.tsx
b/airflow/www/static/js/dag/details/index.tsx
index afced331f0..56f5b5a2cb 100644
--- a/airflow/www/static/js/dag/details/index.tsx
+++ b/airflow/www/static/js/dag/details/index.tsx
@@ -236,7 +236,9 @@ const Details = ({
dagRunId: runId || "",
taskId: taskId || "",
mapIndex,
- enabled: mapIndex !== undefined,
+ options: {
+ enabled: mapIndex !== undefined,
+ },
});
const instance =
diff --git
a/airflow/www/static/js/dag/details/taskInstance/DatasetUpdateEvents.tsx
b/airflow/www/static/js/dag/details/taskInstance/DatasetUpdateEvents.tsx
index ad1ecf9245..bbcf588685 100644
--- a/airflow/www/static/js/dag/details/taskInstance/DatasetUpdateEvents.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/DatasetUpdateEvents.tsx
@@ -19,22 +19,22 @@
import React, { useMemo } from "react";
import { Box, Text } from "@chakra-ui/react";
-import {
- CodeCell,
- DatasetLink,
- Table,
- TimeCell,
- TriggeredRuns,
-} from "src/components/Table";
import { useDatasetEvents } from "src/api";
import type { DagRun as DagRunType } from "src/types";
import { getMetaValue } from "src/utils";
+import { CardDef, CardList } from "src/components/Table";
+import type { DatasetEvent } from "src/types/api-generated";
+import DatasetEventCard from "src/components/DatasetEventCard";
interface Props {
runId: DagRunType["runId"];
taskId: string;
}
+const cardDef: CardDef<DatasetEvent> = {
+ card: ({ row }) => <DatasetEventCard datasetEvent={row} />,
+};
+
const dagId = getMetaValue("dag_id") || undefined;
const DatasetUpdateEvents = ({ runId, taskId }: Props) => {
@@ -50,25 +50,24 @@ const DatasetUpdateEvents = ({ runId, taskId }: Props) => {
const columns = useMemo(
() => [
{
- Header: "Dataset URI",
+ Header: "When",
+ accessor: "timestamp",
+ },
+ {
+ Header: "Dataset",
accessor: "datasetUri",
- Cell: DatasetLink,
},
{
- Header: "When",
- accessor: "timestamp",
- Cell: TimeCell,
+ Header: "Source Task Instance",
+ accessor: "sourceTaskId",
},
{
Header: "Triggered Runs",
accessor: "createdDagruns",
- Cell: TriggeredRuns,
},
{
Header: "Extra",
accessor: "extra",
- Cell: CodeCell,
- disableSortBy: true,
},
],
[]
@@ -82,7 +81,12 @@ const DatasetUpdateEvents = ({ runId, taskId }: Props) => {
Dataset Events
</Text>
<Text>Dataset updates caused by this task instance</Text>
- <Table data={data} columns={columns} isLoading={isLoading} />
+ <CardList
+ data={data}
+ columns={columns}
+ isLoading={isLoading}
+ cardDef={cardDef}
+ />
</Box>
);
};
diff --git a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
index f6d7c83b35..68db258d10 100644
--- a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
@@ -25,10 +25,9 @@ import type { Row, SortingRule } from "react-table";
import { formatDuration, getDuration } from "src/datetime_utils";
import { useMappedInstances } from "src/api";
import { StatusWithNotes } from "src/dag/StatusBox";
-import { Table } from "src/components/Table";
+import { Table, CellProps } from "src/components/Table";
import Time from "src/components/Time";
import { useOffsetTop } from "src/utils";
-import type { CellProps } from "src/components/Table";
interface Props {
dagId: string;
@@ -94,8 +93,11 @@ const MappedInstances = ({ dagId, runId, taskId,
onRowClicked }: Props) => {
{
Header: "Map Index",
accessor: "mapIndex",
- Cell: ({ cell: { row } }: CellProps) =>
- row.original.renderedMapIndex || row.original.mapIndex,
+ Cell: ({
+ cell: {
+ row: { original },
+ },
+ }: CellProps) => original.renderedMapIndex || original.mapIndex,
},
{
Header: "State",
diff --git a/airflow/www/static/js/dag/details/taskInstance/index.tsx
b/airflow/www/static/js/dag/details/taskInstance/index.tsx
index f33f3e23ad..74f317867a 100644
--- a/airflow/www/static/js/dag/details/taskInstance/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/index.tsx
@@ -65,7 +65,9 @@ const TaskInstance = ({ taskId, runId, mapIndex }: Props) => {
dagRunId: runId,
taskId,
mapIndex,
- enabled: (!isGroup && !isMapped) || isMapIndexDefined,
+ options: {
+ enabled: (!isGroup && !isMapped) || isMapIndexDefined,
+ },
});
const showTaskSchedulingDependencies =
diff --git a/airflow/www/static/js/datasets/DatasetDetails.tsx
b/airflow/www/static/js/datasets/DatasetDetails.tsx
new file mode 100644
index 0000000000..a4a4797a06
--- /dev/null
+++ b/airflow/www/static/js/datasets/DatasetDetails.tsx
@@ -0,0 +1,142 @@
+/*!
+ * 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 {
+ Spinner,
+ Flex,
+ IconButton,
+ useDisclosure,
+ Grid,
+ GridItem,
+ Heading,
+ Link,
+ Box,
+} from "@chakra-ui/react";
+import { MdPlayArrow } from "react-icons/md";
+import { isEmpty } from "lodash";
+
+import { useDataset } from "src/api";
+import { useContainerRef } from "src/context/containerRef";
+import Tooltip from "src/components/Tooltip";
+import { getMetaValue } from "src/utils";
+import RenderedJsonField from "src/components/RenderedJsonField";
+
+import CreateDatasetEventModal from "./CreateDatasetEvent";
+import Events from "./DatasetEvents";
+
+const gridUrl = getMetaValue("grid_url");
+
+interface Props {
+ uri: string;
+}
+
+const DatasetDetails = ({ uri }: Props) => {
+ const { data: dataset, isLoading } = useDataset({ uri });
+ const { isOpen, onToggle, onClose } = useDisclosure();
+ const containerRef = useContainerRef();
+
+ const hasProducingTasks = !!dataset?.producingTasks?.length;
+ const hasConsumingDags = !!dataset?.consumingDags?.length;
+
+ return (
+ <Flex flexDirection="column">
+ {isLoading && <Spinner display="block" />}
+ <Grid templateColumns="repeat(5, 1fr)">
+ {hasProducingTasks && (
+ <GridItem colSpan={hasConsumingDags ? 2 : 4}>
+ <Heading size="sm">Tasks that update this Dataset</Heading>
+ {dataset?.producingTasks?.map((task) => {
+ if (!task.taskId || !task.dagId) return null;
+ const url = `${gridUrl?.replace(
+ "__DAG_ID__",
+ task.dagId
+ )}?&task_id=${encodeURIComponent(task.taskId)}`;
+ return (
+ <Link
+ key={`${task.dagId}.${task.taskId}`}
+ color="blue.600"
+ href={url}
+ display="block"
+ >
+ {task.dagId}.{task.taskId}
+ </Link>
+ );
+ })}
+ </GridItem>
+ )}
+ {hasConsumingDags && (
+ <GridItem colSpan={hasProducingTasks ? 2 : 4}>
+ <Heading size="sm">DAGs that consume this Dataset</Heading>
+ {dataset?.consumingDags?.map((dag) => {
+ if (!dag.dagId) return null;
+ const url = gridUrl?.replace("__DAG_ID__", dag.dagId);
+ return (
+ <Link
+ display="block"
+ key={`${dag.dagId}`}
+ color="blue.600"
+ href={url}
+ >
+ {dag.dagId}
+ </Link>
+ );
+ })}
+ </GridItem>
+ )}
+ <GridItem colSpan={1} display="flex" justifyContent="flex-end">
+ <Tooltip
+ label="Manually create dataset event"
+ hasArrow
+ portalProps={{ containerRef }}
+ >
+ <IconButton
+ variant="outline"
+ colorScheme="blue"
+ aria-label="Manually create dataset event"
+ onClick={onToggle}
+ >
+ <MdPlayArrow />
+ </IconButton>
+ </Tooltip>
+ </GridItem>
+ </Grid>
+ {dataset?.extra && !isEmpty(dataset?.extra) && (
+ <RenderedJsonField
+ content={dataset.extra}
+ bg="gray.100"
+ maxH="300px"
+ overflow="auto"
+ />
+ )}
+ <Box mt={2}>
+ {dataset && dataset.id && <Events datasetId={dataset.id} showLabel />}
+ </Box>
+ {dataset && (
+ <CreateDatasetEventModal
+ isOpen={isOpen}
+ onClose={onClose}
+ dataset={dataset}
+ />
+ )}
+ </Flex>
+ );
+};
+
+export default DatasetDetails;
diff --git a/airflow/www/static/js/datasets/DatasetEvents.tsx
b/airflow/www/static/js/datasets/DatasetEvents.tsx
index b58006ac81..5cec880308 100644
--- a/airflow/www/static/js/datasets/DatasetEvents.tsx
+++ b/airflow/www/static/js/datasets/DatasetEvents.tsx
@@ -20,17 +20,24 @@
import React, { useMemo, useState } from "react";
import { snakeCase } from "lodash";
import type { SortingRule } from "react-table";
+import { Box, Flex, Heading, Select } from "@chakra-ui/react";
import { useDatasetEvents } from "src/api";
-import {
- Table,
- TimeCell,
- TaskInstanceLink,
- TriggeredRuns,
- CodeCell,
-} from "src/components/Table";
-const Events = ({ datasetId }: { datasetId: number }) => {
+import { CardList, type CardDef } from "src/components/Table";
+import type { DatasetEvent } from "src/types/api-generated";
+import DatasetEventCard from "src/components/DatasetEventCard";
+
+type Props = {
+ datasetId?: number;
+ showLabel?: boolean;
+};
+
+const cardDef: CardDef<DatasetEvent> = {
+ card: ({ row }) => <DatasetEventCard datasetEvent={row} />,
+};
+
+const Events = ({ datasetId, showLabel }: Props) => {
const limit = 25;
const [offset, setOffset] = useState(0);
const [sortBy, setSortBy] = useState<SortingRule<object>[]>([
@@ -52,26 +59,25 @@ const Events = ({ datasetId }: { datasetId: number }) => {
const columns = useMemo(
() => [
- {
- Header: "Source Task Instance",
- accessor: "sourceTaskId",
- Cell: TaskInstanceLink,
- },
{
Header: "When",
accessor: "timestamp",
- Cell: TimeCell,
+ },
+ {
+ Header: "Dataset",
+ accessor: "datasetUri",
+ },
+ {
+ Header: "Source Task Instance",
+ accessor: "sourceTaskId",
},
{
Header: "Triggered Runs",
accessor: "createdDagruns",
- Cell: TriggeredRuns,
},
{
Header: "Extra",
accessor: "extra",
- Cell: CodeCell,
- disableSortBy: true,
},
],
[]
@@ -79,25 +85,44 @@ const Events = ({ datasetId }: { datasetId: number }) => {
const data = useMemo(() => datasetEvents, [datasetEvents]);
- const memoSort = useMemo(() => sortBy, [sortBy]);
-
return (
- <Table
- data={data}
- columns={columns}
- manualPagination={{
- offset,
- setOffset,
- totalEntries,
- }}
- manualSort={{
- setSortBy,
- sortBy,
- initialSortBy: memoSort,
- }}
- pageSize={limit}
- isLoading={isEventsLoading}
- />
+ <Box>
+ <Flex justifyContent="space-between" alignItems="center">
+ <Heading size="sm">{showLabel && "Events"}</Heading>
+ <Flex alignItems="center" alignSelf="flex-end">
+ Sort:
+ <Select
+ ml={2}
+ value={orderBy}
+ onChange={({ target: { value } }) => {
+ const isDesc = value.startsWith("-");
+ setSortBy([
+ {
+ id: isDesc ? value.slice(0, value.length) : value,
+ desc: isDesc,
+ },
+ ]);
+ }}
+ width="200px"
+ >
+ <option value="-timestamp">Timestamp - Desc</option>
+ <option value="timestamp">Timestamp - Asc</option>
+ </Select>
+ </Flex>
+ </Flex>
+ <CardList
+ data={data}
+ columns={columns}
+ manualPagination={{
+ offset,
+ setOffset,
+ totalEntries,
+ }}
+ pageSize={limit}
+ isLoading={isEventsLoading}
+ cardDef={cardDef}
+ />
+ </Box>
);
};
diff --git a/airflow/www/static/js/datasets/List.test.tsx
b/airflow/www/static/js/datasets/DatasetsList.test.tsx
similarity index 96%
rename from airflow/www/static/js/datasets/List.test.tsx
rename to airflow/www/static/js/datasets/DatasetsList.test.tsx
index c445cc2d68..89d0e9f490 100644
--- a/airflow/www/static/js/datasets/List.test.tsx
+++ b/airflow/www/static/js/datasets/DatasetsList.test.tsx
@@ -27,7 +27,7 @@ import { Wrapper } from "src/utils/testUtils";
import type { UseQueryResult } from "react-query";
import type { DatasetListItem } from "src/types";
-import DatasetsList from "./List";
+import DatasetsList from "./DatasetsList";
const datasets = [
{
@@ -87,7 +87,7 @@ describe("Test Datasets List", () => {
.mockImplementation(() => returnValue);
const { getByText, queryAllByTestId } = render(
- <DatasetsList onSelectNode={() => {}} />,
+ <DatasetsList onSelect={() => {}} />,
{ wrapper: Wrapper }
);
@@ -111,7 +111,7 @@ describe("Test Datasets List", () => {
.mockImplementation(() => emptyReturnValue);
const { getByText, queryAllByTestId, getByTestId } = render(
- <DatasetsList onSelectNode={() => {}} />,
+ <DatasetsList onSelect={() => {}} />,
{ wrapper: Wrapper }
);
diff --git a/airflow/www/static/js/datasets/List.tsx
b/airflow/www/static/js/datasets/DatasetsList.tsx
similarity index 80%
rename from airflow/www/static/js/datasets/List.tsx
rename to airflow/www/static/js/datasets/DatasetsList.tsx
index b8753dfcd7..ffc0c4a08e 100644
--- a/airflow/www/static/js/datasets/List.tsx
+++ b/airflow/www/static/js/datasets/DatasetsList.tsx
@@ -18,43 +18,21 @@
*/
import React, { useMemo, useState } from "react";
-import {
- Box,
- Heading,
- Flex,
- Text,
- Link,
- ButtonGroup,
- Button,
-} from "@chakra-ui/react";
+import { Box, Flex, Text, Link, ButtonGroup, Button } from "@chakra-ui/react";
import { snakeCase } from "lodash";
import type { Row, SortingRule } from "react-table";
import { useSearchParams } from "react-router-dom";
import { useDatasetsSummary } from "src/api";
-import { Table, TimeCell } from "src/components/Table";
+import { CellProps, Table, TimeCell } from "src/components/Table";
import type { API } from "src/types";
import { getMetaValue } from "src/utils";
import type { DateOption } from "src/api/useDatasetsSummary";
-import type { DatasetDependencies } from "src/api/useDatasetDependencies";
-import SearchBar from "./SearchBar";
-interface Props {
- datasetDependencies?: DatasetDependencies;
- selectedDagId?: string;
- selectedUri?: string;
- onSelectNode: (id: string, type: string) => void;
-}
+import type { OnSelectProps } from "./types";
-interface CellProps {
- cell: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- value: any;
- row: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- original: Record<string, any>;
- };
- };
+interface Props {
+ onSelect: (props: OnSelectProps) => void;
}
const DetailCell = ({ cell: { row } }: CellProps) => {
@@ -78,12 +56,7 @@ const dateOptions: Record<string, DateOption> = {
hour: { count: 1, unit: "hour" },
};
-const DatasetsList = ({
- datasetDependencies,
- onSelectNode,
- selectedDagId,
- selectedUri,
-}: Props) => {
+const DatasetsList = ({ onSelect }: Props) => {
const limit = 25;
const [offset, setOffset] = useState(0);
@@ -104,7 +77,6 @@ const DatasetsList = ({
limit,
offset,
order,
- // uri,
updatedAfter: dateFilter ? dateOptions[dateFilter] : undefined,
});
@@ -128,18 +100,13 @@ const DatasetsList = ({
const memoSort = useMemo(() => sortBy, [sortBy]);
const onDatasetSelect = (row: Row<API.Dataset>) => {
- if (row.original.uri) onSelectNode(row.original.uri, "dataset");
+ if (row.original.uri) onSelect({ uri: row.original.uri });
};
const docsUrl = getMetaValue("datasets_docs");
return (
- <Box>
- <Flex justifyContent="space-between" alignItems="center">
- <Heading mt={3} mb={2} fontWeight="normal" size="lg">
- Datasets
- </Heading>
- </Flex>
+ <>
{!datasets.length && !isLoading && !dateFilter && (
<Text mb={4} data-testid="no-datasets-msg">
Looks like you do not have any datasets yet. Check out the{" "}
@@ -185,12 +152,6 @@ const DatasetsList = ({
})}
</ButtonGroup>
</Flex>
- <SearchBar
- datasetDependencies={datasetDependencies}
- selectedDagId={selectedDagId}
- selectedUri={selectedUri}
- onSelectNode={onSelectNode}
- />
<Box borderWidth={1} mt={2}>
<Table
data={data}
@@ -210,7 +171,7 @@ const DatasetsList = ({
onRowClicked={onDatasetSelect}
/>
</Box>
- </Box>
+ </>
);
};
diff --git a/airflow/www/static/js/datasets/Details.tsx
b/airflow/www/static/js/datasets/Details.tsx
deleted file mode 100644
index cb0f83fd45..0000000000
--- a/airflow/www/static/js/datasets/Details.tsx
+++ /dev/null
@@ -1,97 +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.
- */
-
-import React from "react";
-import {
- Box,
- Heading,
- Flex,
- Spinner,
- Button,
- IconButton,
- useDisclosure,
-} from "@chakra-ui/react";
-import { MdPlayArrow } from "react-icons/md";
-
-import { useDataset } from "src/api";
-import { ClipboardButton } from "src/components/Clipboard";
-import InfoTooltip from "src/components/InfoTooltip";
-import { useContainerRef } from "src/context/containerRef";
-import Tooltip from "src/components/Tooltip";
-
-import CreateDatasetEventModal from "./CreateDatasetEvent";
-import Events from "./DatasetEvents";
-
-interface Props {
- uri: string;
- onBack: () => void;
-}
-
-const DatasetDetails = ({ uri, onBack }: Props) => {
- const { data: dataset, isLoading } = useDataset({ uri });
- const { isOpen, onToggle, onClose } = useDisclosure();
- const containerRef = useContainerRef();
- return (
- <Box mt={[6, 3]}>
- <Flex alignItems="center" justifyContent="space-between">
- <Button onClick={onBack}>See all datasets</Button>
- <Tooltip
- label="Manually create dataset event"
- hasArrow
- portalProps={{ containerRef }}
- >
- <IconButton
- variant="outline"
- colorScheme="blue"
- aria-label="Manually create dataset event"
- onClick={onToggle}
- >
- <MdPlayArrow />
- </IconButton>
- </Tooltip>
- </Flex>
- {isLoading && <Spinner display="block" />}
- <Box>
- <Heading my={2} fontWeight="normal" size="lg">
- Dataset: {uri}
- <ClipboardButton value={uri} iconOnly ml={2} />
- </Heading>
- </Box>
- <Flex alignItems="center">
- <Heading size="md" mt={3} mb={2} fontWeight="normal">
- History
- </Heading>
- <InfoTooltip
- label="Whenever a DAG has updated this dataset."
- size={18}
- />
- </Flex>
- {dataset && dataset.id && <Events datasetId={dataset.id} />}
- {dataset && (
- <CreateDatasetEventModal
- isOpen={isOpen}
- onClose={onClose}
- dataset={dataset}
- />
- )}
- </Box>
- );
-};
-
-export default DatasetDetails;
diff --git a/airflow/www/static/js/datasets/Graph/Node.tsx
b/airflow/www/static/js/datasets/Graph/Node.tsx
index 425f25e3c9..72b5505ffb 100644
--- a/airflow/www/static/js/datasets/Graph/Node.tsx
+++ b/airflow/www/static/js/datasets/Graph/Node.tsx
@@ -32,7 +32,7 @@ export interface CustomNodeProps {
width?: number;
isSelected?: boolean;
isHighlighted?: boolean;
- onSelect: (datasetUri: string, type: string) => void;
+ onSelect: () => void;
isOpen?: boolean;
isActive?: boolean;
}
@@ -62,7 +62,7 @@ const BaseNode = ({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
- onSelect(label, "dataset");
+ onSelect();
}}
cursor="pointer"
fontSize={16}
diff --git a/airflow/www/static/js/datasets/Graph/index.tsx
b/airflow/www/static/js/datasets/Graph/index.tsx
index 6948603112..9157a8a1a2 100644
--- a/airflow/www/static/js/datasets/Graph/index.tsx
+++ b/airflow/www/static/js/datasets/Graph/index.tsx
@@ -38,16 +38,17 @@ import { useDatasetGraphs } from
"src/api/useDatasetDependencies";
import Node, { CustomNodeProps } from "./Node";
import Legend from "./Legend";
+import type { OnSelectProps } from "../types";
interface Props {
- selectedNodeId: string | null;
- onSelectNode: (id: string, type: string) => void;
+ selectedNodeId?: string;
+ onSelect?: (props: OnSelectProps) => void;
}
const nodeTypes = { custom: Node };
const edgeTypes = { custom: Edge };
-const Graph = ({ selectedNodeId, onSelectNode }: Props) => {
+const Graph = ({ selectedNodeId, onSelect }: Props) => {
const { colors } = useTheme();
const { setCenter } = useReactFlow();
const containerRef = useContainerRef();
@@ -84,7 +85,13 @@ const Graph = ({ selectedNodeId, onSelectNode }: Props) => {
type: c.value.class,
width: c.width,
height: c.height,
- onSelect: onSelectNode,
+ onSelect: () => {
+ if (onSelect) {
+ if (c.value.class === "dataset") onSelect({ uri: c.value.label });
+ else if (c.value.class === "dag")
+ onSelect({ dagId: c.value.label });
+ }
+ },
isSelected: selectedNodeId === c.value.label,
isHighlighted: edges.some(
(e) => e.data.rest.isSelected && e.id.includes(c.id)
diff --git a/airflow/www/static/js/datasets/Main.tsx
b/airflow/www/static/js/datasets/Main.tsx
index b73c4fca2e..04380e2665 100644
--- a/airflow/www/static/js/datasets/Main.tsx
+++ b/airflow/www/static/js/datasets/Main.tsx
@@ -17,138 +17,239 @@
* under the License.
*/
-import React, { useCallback, useEffect, useRef } from "react";
+import React, { useCallback, useRef } from "react";
import { useSearchParams } from "react-router-dom";
-import { Flex, Box, Spinner } from "@chakra-ui/react";
+import {
+ Box,
+ Breadcrumb,
+ BreadcrumbLink,
+ BreadcrumbItem,
+ Heading,
+ Tabs,
+ Spinner,
+ Tab,
+ TabList,
+ TabPanel,
+ TabPanels,
+ Text,
+} from "@chakra-ui/react";
+import { HiDatabase } from "react-icons/hi";
+import { MdEvent, MdAccountTree, MdDetails } from "react-icons/md";
+import Time from "src/components/Time";
+import BreadcrumbText from "src/components/BreadcrumbText";
import { useOffsetTop } from "src/utils";
import { useDatasetDependencies } from "src/api";
+import URLSearchParamsWrapper from "src/utils/URLSearchParamWrapper";
-import DatasetsList from "./List";
-import DatasetDetails from "./Details";
+import DatasetEvents from "./DatasetEvents";
+import DatasetsList from "./DatasetsList";
+import DatasetDetails from "./DatasetDetails";
+import type { OnSelectProps } from "./types";
import Graph from "./Graph";
+import SearchBar from "./SearchBar";
const DATASET_URI_PARAM = "uri";
const DAG_ID_PARAM = "dag_id";
-const minPanelWidth = 300;
+const TIMESTAMP_PARAM = "timestamp";
+const TAB_PARAM = "tab";
+
+const tabToIndex = (tab?: string) => {
+ switch (tab) {
+ case "graph":
+ return 1;
+ case "datasets":
+ return 2;
+ case "details":
+ case "events":
+ default:
+ return 0;
+ }
+};
+
+const indexToTab = (index: number, uri?: string) => {
+ switch (index) {
+ case 0:
+ return uri ? "details" : "events";
+ case 1:
+ return "graph";
+ case 2:
+ if (!uri) return "datasets";
+ return undefined;
+ default:
+ return undefined;
+ }
+};
const Datasets = () => {
- const [searchParams, setSearchParams] = useSearchParams();
const contentRef = useRef<HTMLDivElement>(null);
const offsetTop = useOffsetTop(contentRef);
- const listRef = useRef<HTMLDivElement>(null);
- const graphRef = useRef<HTMLDivElement>(null);
- // 60px for footer height
- const height = `calc(100vh - ${offsetTop + 60}px)`;
+ const height = `calc(100vh - ${offsetTop + 100}px)`;
- const resizeRef = useRef<HTMLDivElement>(null);
+ const { data: datasetDependencies, isLoading } = useDatasetDependencies();
+ const [searchParams, setSearchParams] = useSearchParams();
const selectedUri = decodeURIComponent(
searchParams.get(DATASET_URI_PARAM) || ""
);
- const selectedDagId = searchParams.get(DAG_ID_PARAM);
- // We need to load in the raw dependencies in order to generate the list of
dagIds
- const { data: datasetDependencies, isLoading } = useDatasetDependencies();
-
- const resize = useCallback(
- (e: MouseEvent) => {
- const listEl = listRef.current;
- if (
- listEl &&
- e.x > minPanelWidth &&
- e.x < window.innerWidth - minPanelWidth
- ) {
- const width = `${e.x}px`;
- listEl.style.width = width;
- }
- },
- [listRef]
+ const selectedTimestamp = decodeURIComponent(
+ searchParams.get(TIMESTAMP_PARAM) || ""
);
+ const selectedDagId = searchParams.get(DAG_ID_PARAM) || undefined;
- useEffect(() => {
- const resizeEl = resizeRef.current;
- if (resizeEl) {
- resizeEl.addEventListener("mousedown", (e) => {
- e.preventDefault();
- document.addEventListener("mousemove", resize);
- });
-
- document.addEventListener("mouseup", () => {
- document.removeEventListener("mousemove", resize);
- });
-
- return () => {
- resizeEl?.removeEventListener("mousedown", resize);
- document.removeEventListener("mouseup", resize);
- };
- }
- return () => {};
- }, [resize]);
+ const tab = searchParams.get(TAB_PARAM) || undefined;
+ const tabIndex = tabToIndex(tab);
- const selectedNodeId = selectedUri || selectedDagId;
+ const onChangeTab = useCallback(
+ (index: number) => {
+ const params = new URLSearchParamsWrapper(searchParams);
+ const newTab = indexToTab(index, selectedUri);
+ if (newTab) params.set(TAB_PARAM, newTab);
+ else params.delete(TAB_PARAM);
+ setSearchParams(params);
+ },
+ [setSearchParams, searchParams, selectedUri]
+ );
- const onSelectNode = (id: string, type: string) => {
- if (type === "dag") {
- if (id === selectedDagId) searchParams.delete(DAG_ID_PARAM);
- else searchParams.set(DAG_ID_PARAM, id);
+ const onSelect = ({ uri, timestamp, dagId }: OnSelectProps = {}) => {
+ if (dagId) {
+ if (dagId === selectedDagId) searchParams.delete(DAG_ID_PARAM);
+ searchParams.set(DAG_ID_PARAM, dagId);
searchParams.delete(DATASET_URI_PARAM);
- }
- if (type === "dataset") {
- if (id === selectedUri) searchParams.delete(DATASET_URI_PARAM);
- else searchParams.set(DATASET_URI_PARAM, id);
+ } else if (uri) {
+ searchParams.set(DATASET_URI_PARAM, uri);
+ if (timestamp) searchParams.set(TIMESTAMP_PARAM, timestamp);
+ else searchParams.delete(TIMESTAMP_PARAM);
+ searchParams.delete(DAG_ID_PARAM);
+ if (tab === "datasets") searchParams.delete(TAB_PARAM);
+ } else {
+ searchParams.delete(DATASET_URI_PARAM);
+ searchParams.delete(TIMESTAMP_PARAM);
searchParams.delete(DAG_ID_PARAM);
}
setSearchParams(searchParams);
};
return (
- <Flex
- alignItems="flex-start"
- justifyContent="space-between"
- ref={contentRef}
- >
- <Box
- minWidth={minPanelWidth}
- width={500}
- height={height}
- overflowY="auto"
- ref={listRef}
- mr={3}
+ <Box alignItems="flex-start" justifyContent="space-between">
+ <Breadcrumb
+ ml={3}
+ pt={2}
+ mt={4}
+ separator={
+ <Heading as="h3" size="md" color="gray.300">
+ /
+ </Heading>
+ }
>
- {selectedUri ? (
- <DatasetDetails
- uri={selectedUri}
- onBack={() => onSelectNode(selectedUri, "dataset")}
- />
- ) : (
- <DatasetsList
- datasetDependencies={datasetDependencies}
- selectedDagId={selectedDagId || undefined}
- onSelectNode={onSelectNode}
- />
+ <BreadcrumbItem>
+ <BreadcrumbLink
+ onClick={() => onSelect()}
+ isCurrentPage={!selectedUri}
+ >
+ <Heading as="h3" size="md">
+ Datasets
+ </Heading>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+
+ {selectedUri && (
+ <BreadcrumbItem isCurrentPage={!!selectedUri && !selectedTimestamp}>
+ <BreadcrumbLink onClick={() => onSelect({ uri: selectedUri })}>
+ <BreadcrumbText label="URI" value={selectedUri} />
+ </BreadcrumbLink>
+ </BreadcrumbItem>
)}
- </Box>
- <Box
- width={2}
- cursor="ew-resize"
- bg="gray.200"
- ref={resizeRef}
- zIndex={1}
- height={height}
- />
- <Box
- ref={graphRef}
- flex={1}
- height={height}
- borderColor="gray.200"
- borderWidth={1}
- position="relative"
- >
- {isLoading && <Spinner position="absolute" top="50%" left="50%" />}
- <Graph selectedNodeId={selectedNodeId} onSelectNode={onSelectNode} />
- </Box>
- </Flex>
+
+ {selectedTimestamp && (
+ <BreadcrumbItem isCurrentPage={!!selectedTimestamp}>
+ <BreadcrumbLink>
+ <BreadcrumbText
+ label="Timestamp"
+ value={<Time dateTime={selectedTimestamp} />}
+ />
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ )}
+ </Breadcrumb>
+ <Tabs ref={contentRef} isLazy index={tabIndex} onChange={onChangeTab}>
+ <TabList>
+ {!selectedUri && (
+ <Tab>
+ <MdEvent size={16} />
+ <Text as="strong" ml={1}>
+ Dataset Events
+ </Text>
+ </Tab>
+ )}
+ {!!selectedUri && (
+ <Tab>
+ <MdDetails size={16} />
+ <Text as="strong" ml={1}>
+ Details
+ </Text>
+ </Tab>
+ )}
+ <Tab>
+ <MdAccountTree size={16} />
+ <Text as="strong" ml={1}>
+ Dependency Graph
+ </Text>
+ </Tab>
+ {!selectedUri && (
+ <Tab>
+ <HiDatabase size={16} />
+ <Text as="strong" ml={1}>
+ Datasets
+ </Text>
+ </Tab>
+ )}
+ </TabList>
+ <TabPanels>
+ {!selectedUri && (
+ <TabPanel>
+ <DatasetEvents />
+ </TabPanel>
+ )}
+ {!!selectedUri && (
+ <TabPanel>
+ <DatasetDetails uri={selectedUri} />
+ </TabPanel>
+ )}
+ <TabPanel>
+ {isLoading && <Spinner position="absolute" top="50%" left="50%" />}
+ {/* the graph needs a defined height to render properly */}
+ <SearchBar
+ datasetDependencies={datasetDependencies}
+ selectedDagId={selectedDagId}
+ selectedUri={selectedUri}
+ onSelectNode={onSelect}
+ />
+ <Box
+ flex={1}
+ height={height}
+ borderColor="gray.200"
+ borderWidth={1}
+ position="relative"
+ mt={2}
+ >
+ {height && (
+ <Graph
+ selectedNodeId={selectedUri || selectedDagId}
+ onSelect={onSelect}
+ />
+ )}
+ </Box>
+ </TabPanel>
+ {!selectedUri && (
+ <TabPanel>
+ <DatasetsList onSelect={onSelect} />
+ </TabPanel>
+ )}
+ </TabPanels>
+ </Tabs>
+ </Box>
);
};
diff --git a/airflow/www/static/js/datasets/SearchBar.tsx
b/airflow/www/static/js/datasets/SearchBar.tsx
index 4702035bf2..fc47215389 100644
--- a/airflow/www/static/js/datasets/SearchBar.tsx
+++ b/airflow/www/static/js/datasets/SearchBar.tsx
@@ -21,12 +21,13 @@ import React from "react";
import { Select, SingleValue, useChakraSelectProps } from
"chakra-react-select";
import type { DatasetDependencies } from "src/api/useDatasetDependencies";
+import type { OnSelectProps } from "./types";
interface Props {
datasetDependencies?: DatasetDependencies;
selectedDagId?: string;
selectedUri?: string;
- onSelectNode: (id: string, type: string) => void;
+ onSelectNode: (props: OnSelectProps) => void;
}
interface Option {
@@ -54,7 +55,8 @@ const SearchBar = ({
if (option) {
if (option.value.startsWith("dataset:")) type = "dataset";
else if (option.value.startsWith("dag:")) type = "dag";
- if (type) onSelectNode(option.label, type);
+ if (type === "dag") onSelectNode({ dagId: option.label });
+ else if (type === "dataset") onSelectNode({ uri: option.label });
}
};
diff --git a/airflow/www/static/js/dag/details/BreadcrumbText.tsx
b/airflow/www/static/js/datasets/types.ts
similarity index 61%
rename from airflow/www/static/js/dag/details/BreadcrumbText.tsx
rename to airflow/www/static/js/datasets/types.ts
index b9ecdbeb20..98e7613d14 100644
--- a/airflow/www/static/js/dag/details/BreadcrumbText.tsx
+++ b/airflow/www/static/js/datasets/types.ts
@@ -17,30 +17,8 @@
* under the License.
*/
-import React from "react";
-import { Box, Heading } from "@chakra-ui/react";
-
-interface Props {
- label: string;
- value: React.ReactNode;
-}
-
-const BreadcrumbText = ({ label, value }: Props) => (
- <Box position="relative">
- <Heading
- as="h5"
- size="sm"
- color="gray.300"
- position="absolute"
- top="-12px"
- whiteSpace="nowrap"
- >
- {label}
- </Heading>
- <Heading as="h3" size="md">
- {value}
- </Heading>
- </Box>
-);
-
-export default BreadcrumbText;
+export type OnSelectProps = {
+ uri?: string;
+ timestamp?: string;
+ dagId?: string;
+};
diff --git a/airflow/www/templates/airflow/dag.html
b/airflow/www/templates/airflow/dag.html
index e18a1486bd..f4174e55f0 100644
--- a/airflow/www/templates/airflow/dag.html
+++ b/airflow/www/templates/airflow/dag.html
@@ -74,7 +74,7 @@
<meta name="task_xcom_entries_api" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_xcom_endpoint_get_xcom_entries',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_') }}">
<meta name="task_xcom_entry_api" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_xcom_endpoint_get_xcom_entry',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_',
xcom_key='_XCOM_KEY_') }}">
<meta name="upstream_dataset_events_api" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_dag_run_endpoint_get_upstream_dataset_events',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_') }}">
- <meta name="task_instance_api" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_get_task_instance',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_') }}">
+ <meta name="task_instance_api" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_get_task_instance',
dag_id='_DAG_ID_', dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_') }}">
<meta name="set_task_instance_note" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_set_task_instance_note',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_' ) }}">
<meta name="set_mapped_task_instance_note" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_set_mapped_task_instance_note',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_', map_index=0
) }}">
<meta name="set_dag_run_note" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_dag_run_endpoint_set_dag_run_note',
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_') }}">
diff --git a/airflow/www/templates/airflow/datasets.html
b/airflow/www/templates/airflow/datasets.html
index ea9295c057..64e08510e4 100644
--- a/airflow/www/templates/airflow/datasets.html
+++ b/airflow/www/templates/airflow/datasets.html
@@ -30,6 +30,7 @@
<meta name="grid_url" content="{{ url_for('Airflow.grid',
dag_id='__DAG_ID__') }}">
<meta name="datasets_docs" content="{{
get_docs_url('concepts/datasets.html') }}">
<meta name="dataset_dependencies_url" content="{{
url_for('Airflow.dataset_dependencies') }}">
+ <meta name="task_instance_api" content="{{
url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_get_task_instance',
dag_id='_DAG_ID_', dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_') }}">
{% endblock %}
{% block content %}
@@ -44,6 +45,7 @@
{{ super()}}
<script>
const stateColors = {{ state_color_mapping|tojson }};
+ const autoRefreshInterval = {{ auto_refresh_interval }};
</script>
<script src="{{ url_for_asset('datasets.js') }}"></script>
{% endblock %}
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 6d54cf93c0..0e0923aa13 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -1161,6 +1161,7 @@ class Airflow(AirflowBaseView):
state_color_mapping["null"] = state_color_mapping.pop(None)
return self.render_template(
"airflow/datasets.html",
+ auto_refresh_interval=conf.getint("webserver",
"auto_refresh_interval"),
state_color_mapping=state_color_mapping,
)