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,
         )
 

Reply via email to