diff --git a/airflow/www/static/js/api/index.ts b/airflow/www/static/js/api/index.ts
index 054e831008d4b..82758738f0b3a 100644
--- a/airflow/www/static/js/api/index.ts
+++ b/airflow/www/static/js/api/index.ts
@@ -34,6 +34,8 @@ import useConfirmMarkTask from './useConfirmMarkTask';
 import useGridData from './useGridData';
 import useMappedInstances from './useMappedInstances';
 import useDatasets from './useDatasets';
+import useDataset from './useDataset';
+import useDatasetEvents from './useDatasetEvents';
 
 axios.interceptors.response.use(
   (res: AxiosResponse) => (res.data ? camelcaseKeys(res.data, { deep: true }) : res),
@@ -56,4 +58,6 @@ export {
   useGridData,
   useMappedInstances,
   useDatasets,
+  useDataset,
+  useDatasetEvents,
 };
diff --git a/airflow/www/static/js/api/useDataset.ts b/airflow/www/static/js/api/useDataset.ts
new file mode 100644
index 0000000000000..9149f1c1592af
--- /dev/null
+++ b/airflow/www/static/js/api/useDataset.ts
@@ -0,0 +1,38 @@
+/*!
+ * 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 axios, { AxiosResponse } from 'axios';
+import { useQuery } from 'react-query';
+
+import { getMetaValue } from 'src/utils';
+import type { API } from 'src/types';
+
+interface Props {
+  datasetId: string;
+}
+
+export default function useDataset({ datasetId }: Props) {
+  return useQuery(
+    ['dataset', datasetId],
+    () => {
+      const datasetUrl = `${getMetaValue('datasets_api') || '/api/v1/datasets'}/${datasetId}`;
+      return axios.get<AxiosResponse, API.Dataset>(datasetUrl);
+    },
+  );
+}
diff --git a/airflow/www/static/js/api/useDatasetEvents.ts b/airflow/www/static/js/api/useDatasetEvents.ts
new file mode 100644
index 0000000000000..7df7c91a1cd51
--- /dev/null
+++ b/airflow/www/static/js/api/useDatasetEvents.ts
@@ -0,0 +1,73 @@
+/*!
+ * 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 axios, { AxiosResponse } from 'axios';
+import { useQuery } from 'react-query';
+
+import { getMetaValue } from 'src/utils';
+import type { API } from 'src/types';
+
+interface DatasetEventsData {
+  datasetEvents: API.DatasetEvent[];
+  totalEntries: number;
+}
+
+interface Props {
+  datasetId?: string;
+  dagId?: string;
+  taskId?: string;
+  runId?: string;
+  mapIndex?: number;
+  limit?: number;
+  offset?: number;
+  order?: string;
+}
+
+export default function useDatasetEvents({
+  datasetId, dagId, runId, taskId, mapIndex, limit, offset, order,
+}: Props) {
+  const query = useQuery(
+    ['datasets-events', datasetId, dagId, runId, taskId, mapIndex, limit, offset, order],
+    () => {
+      const datasetsUrl = getMetaValue('dataset_events_api') || '/api/v1/datasets/events';
+
+      const params = new URLSearchParams();
+
+      if (limit) params.set('limit', limit.toString());
+      if (offset) params.set('offset', offset.toString());
+      if (order) params.set('order_by', order);
+      if (datasetId) params.set('dataset_id', datasetId);
+      if (dagId) params.set('source_dag_id', dagId);
+      if (runId) params.set('source_run_id', runId);
+      if (taskId) params.set('source_task_id', taskId);
+      if (mapIndex) params.set('source_map_index', mapIndex.toString());
+
+      return axios.get<AxiosResponse, DatasetEventsData>(datasetsUrl, {
+        params,
+      });
+    },
+    {
+      keepPreviousData: true,
+    },
+  );
+  return {
+    ...query,
+    data: query.data ?? { datasetEvents: [], totalEntries: 0 },
+  };
+}
diff --git a/airflow/www/static/js/api/useDatasets.ts b/airflow/www/static/js/api/useDatasets.ts
index 7800a7c6cff0a..e2cf856c3f573 100644
--- a/airflow/www/static/js/api/useDatasets.ts
+++ b/airflow/www/static/js/api/useDatasets.ts
@@ -35,7 +35,7 @@ interface Props {
 }
 
 export default function useDatasets({ limit, offset, order }: Props) {
-  const query = useQuery<DatasetsData>(
+  const query = useQuery(
     ['datasets', limit, offset, order],
     () => {
       const datasetsUrl = getMetaValue('datasets_api') || '/api/v1/datasets';
diff --git a/airflow/www/static/js/components/Table.test.tsx b/airflow/www/static/js/components/Table.test.tsx
index dd6cea13efed6..e7a2f14a9be13 100644
--- a/airflow/www/static/js/components/Table.test.tsx
+++ b/airflow/www/static/js/components/Table.test.tsx
@@ -19,15 +19,16 @@
 
 /* global describe, test, expect */
 
-import React from 'react';
+import React, { useState } from 'react';
 import '@testing-library/jest-dom';
 import { render, fireEvent, within } from '@testing-library/react';
 import { sortBy } from 'lodash';
+import type { SortingRule } from 'react-table';
 
 import Table from './Table';
 import { ChakraWrapper } from '../utils/testUtils';
 
-const data = [
+const data: Record<string, any>[] = [
   { firstName: 'Lamont', lastName: 'Grimes', country: 'United States' },
   { firstName: 'Alysa', lastName: 'Armstrong', country: 'Spain' },
   { firstName: 'Petra', lastName: 'Blick', country: 'France' },
@@ -93,9 +94,28 @@ describe('Test Table', () => {
     expect(getByText('No Data found.')).toBeInTheDocument();
   });
 
-  test('With pagination', async () => {
+  // Simulated pagination that would be done serverside
+  const PaginatedTable = () => {
+    const pageSize = 2;
+    const [offset, setOffset] = useState(0);
+    const filteredData = data.slice(offset, offset + pageSize);
+    return (
+      <Table
+        data={filteredData}
+        columns={columns}
+        pageSize={pageSize}
+        manualPagination={{
+          totalEntries: data.length,
+          offset,
+          setOffset,
+        }}
+      />
+    );
+  };
+
+  test('With manual pagination', async () => {
     const { getAllByRole, queryByText, getByTitle } = render(
-      <Table data={data} columns={columns} pageSize={2} />,
+      <PaginatedTable />,
       { wrapper: ChakraWrapper },
     );
 
@@ -152,10 +172,33 @@ describe('Test Table', () => {
     expect(next).toBeDisabled();
   });
 
-  test('With sorting', async () => {
-    const { getAllByRole } = render(<Table data={data} columns={columns} />, {
-      wrapper: ChakraWrapper,
-    });
+  // Simulate manual sorting that would be done serverside
+  const SortedTable = () => {
+    const [sortByVar, setSortBy] = useState<SortingRule<object>[]>([]);
+    const sort = sortByVar[0];
+    let sortedData = data;
+    if (sort) {
+      sortedData = sort.desc
+        ? sortBy(data, [(o) => o[sort.id]]).reverse()
+        : sortBy(data, [(o) => o[sort.id]]);
+    }
+    return (
+      <Table
+        data={sortedData}
+        columns={columns}
+        manualSort={{
+          sortBy: sortByVar,
+          setSortBy,
+        }}
+      />
+    );
+  };
+
+  test('With manual sorting', async () => {
+    const { getAllByRole } = render(
+      <SortedTable />,
+      { wrapper: ChakraWrapper },
+    );
 
     // Default order matches original data order //
     const firstNameHeader = getAllByRole('columnheader')[0];
diff --git a/airflow/www/static/js/components/Table.tsx b/airflow/www/static/js/components/Table.tsx
index 47c82c2145269..b539ed165c3db 100644
--- a/airflow/www/static/js/components/Table.tsx
+++ b/airflow/www/static/js/components/Table.tsx
@@ -46,6 +46,7 @@ import {
   Column,
   Hooks,
   SortingRule,
+  Row,
 } from 'react-table';
 import {
   MdKeyboardArrowLeft, MdKeyboardArrowRight,
@@ -83,19 +84,26 @@ interface TableProps {
     offset: number;
     setOffset: (offset: number) => void;
   },
+  manualSort?: {
+    sortBy: SortingRule<object>[];
+    setSortBy: (sortBy: SortingRule<object>[]) => void;
+    initialSortBy?: SortingRule<object>[];
+  },
   pageSize?: number;
-  setSortBy?: (sortBy: SortingRule<number>[]) => void;
   isLoading?: boolean;
   selectRows?: (selectedRows: number[]) => void;
+  onRowClicked?: (row: Row<object>, e: any) => void;
 }
+
 const Table = ({
   data,
   columns,
   manualPagination,
+  manualSort,
   pageSize = 25,
-  setSortBy,
   isLoading = false,
   selectRows,
+  onRowClicked,
 }: TableProps) => {
   const { totalEntries, offset, setOffset } = manualPagination || {};
   const oddColor = useColorModeValue('gray.50', 'gray.900');
@@ -143,10 +151,12 @@ const Table = ({
       data,
       pageCount,
       manualPagination: !!manualPagination,
-      manualSortBy: !!setSortBy,
+      manualSortBy: !!manualSort,
+      disableMultiSort: !!manualSort, // API only supporting ordering by a single column
       initialState: {
         pageIndex: offset ? offset / pageSize : 0,
         pageSize,
+        sortBy: manualSort?.initialSortBy || [],
       },
     },
     useSortBy,
@@ -164,9 +174,12 @@ const Table = ({
     if (setOffset) setOffset((pageIndex - 1 || 0) * pageSize);
   };
 
+  // When the sortBy state changes we need to manually call setSortBy
   useEffect(() => {
-    if (setSortBy) setSortBy(sortBy);
-  }, [sortBy, setSortBy]);
+    if (manualSort) {
+      manualSort.setSortBy(sortBy);
+    }
+  }, [sortBy, manualSort]);
 
   useEffect(() => {
     if (selectRows) {
@@ -212,7 +225,8 @@ const Table = ({
               <Tr
                 {...row.getRowProps()}
                 _odd={{ backgroundColor: oddColor }}
-                _hover={{ backgroundColor: hoverColor }}
+                _hover={onRowClicked && { backgroundColor: hoverColor, cursor: 'pointer' }}
+                onClick={onRowClicked ? (e: any) => onRowClicked(row, e) : undefined}
               >
                 {row.cells.map((cell) => (
                   <Td
diff --git a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
index f71cc704a5415..288e757521056 100644
--- a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
@@ -160,7 +160,10 @@ const MappedInstances = ({
           totalEntries,
         }}
         pageSize={limit}
-        setSortBy={setSortBy}
+        manualSort={{
+          setSortBy,
+          sortBy,
+        }}
         isLoading={isLoading}
         selectRows={canEdit ? selectRows : undefined}
       />
diff --git a/airflow/www/static/js/datasets/Details.tsx b/airflow/www/static/js/datasets/Details.tsx
new file mode 100644
index 0000000000000..110406f291bef
--- /dev/null
+++ b/airflow/www/static/js/datasets/Details.tsx
@@ -0,0 +1,163 @@
+/*!
+ * 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, { useMemo, useState } from 'react';
+import {
+  Box, Heading, Text, Code, Flex, Spinner, Button, Link,
+} from '@chakra-ui/react';
+import { snakeCase } from 'lodash';
+import type { SortingRule } from 'react-table';
+
+import Time from 'src/components/Time';
+import { useDatasetEvents, useDataset } from 'src/api';
+import Table from 'src/components/Table';
+import { ClipboardButton } from 'src/components/Clipboard';
+
+interface Props {
+  datasetId: string;
+  onBack: () => void;
+}
+
+const TimeCell = ({ cell: { value } }: any) => <Time dateTime={value} />;
+const GridLink = ({ cell: { value } }: any) => <Link color="blue.500" href={`/dags/${value}/grid`}>{value}</Link>;
+const RunLink = ({ cell: { value, row } }: any) => {
+  const { sourceDagId } = row.original;
+  const url = `/dags/${sourceDagId}/grid?dag_run_id=${encodeURIComponent(value)}`;
+  return (<Link color="blue.500" href={url}>{value}</Link>);
+};
+const TaskInstanceLink = ({ cell: { value, row } }: any) => {
+  const { sourceRunId, sourceDagId } = row.original;
+  const url = `/dags/${sourceDagId}/grid?dag_run_id=${encodeURIComponent(sourceRunId)}&task_id=${encodeURIComponent(value)}`;
+  return (<Link color="blue.500" href={url}>{value}</Link>);
+};
+const CodeCell = ({ cell: { value } }: any) => <Code>{value}</Code>;
+
+const DatasetDetails = ({ datasetId, onBack }: Props) => {
+  const limit = 25;
+  const [offset, setOffset] = useState(0);
+  const [sortBy, setSortBy] = useState<SortingRule<object>[]>([{ id: 'createdAt', desc: true }]);
+
+  const sort = sortBy[0];
+  const order = sort ? `${sort.desc ? '-' : ''}${snakeCase(sort.id)}` : '';
+
+  const { data: dataset, isLoading } = useDataset({ datasetId });
+  const {
+    data: { datasetEvents, totalEntries },
+    isLoading: isEventsLoading,
+  } = useDatasetEvents({
+    datasetId, limit, offset, order,
+  });
+
+  const columns = useMemo(
+    () => [
+      {
+        Header: 'Created At',
+        accessor: 'createdAt',
+        Cell: TimeCell,
+      },
+      {
+        Header: 'Source DAG Id',
+        accessor: 'sourceDagId',
+        Cell: GridLink,
+      },
+      {
+        Header: 'Source DAG Run Id',
+        accessor: 'sourceRunId',
+        Cell: RunLink,
+      },
+      {
+        Header: 'Source Task Id',
+        accessor: 'sourceTaskId',
+        Cell: TaskInstanceLink,
+      },
+      {
+        Header: 'Source Map Index',
+        accessor: 'sourceMapIndex',
+        Cell: ({ cell: { value } }) => (value > -1 ? value : null),
+      },
+      {
+        Header: 'Extra',
+        accessor: 'extra',
+        disableSortBy: true,
+        Cell: CodeCell,
+      },
+    ],
+    [],
+  );
+
+  const data = useMemo(
+    () => datasetEvents,
+    [datasetEvents],
+  );
+
+  const memoSort = useMemo(() => sortBy, [sortBy]);
+
+  return (
+    <Box maxWidth="1500px">
+      <Flex mt={3} justifyContent="space-between">
+        {isLoading && <Spinner display="block" />}
+        {!!dataset && (
+          <Box>
+            <Heading mb={2} fontWeight="normal">
+              Dataset:
+              {' '}
+              {dataset.uri}
+              <ClipboardButton value={dataset.uri} iconOnly ml={2} />
+            </Heading>
+            {!!dataset.extra && (
+              <Flex>
+                <Text mr={1}>Extra:</Text>
+                <Code>{dataset.extra}</Code>
+              </Flex>
+            )}
+            <Flex my={2}>
+              <Text mr={1}>Updated At:</Text>
+              <Time dateTime={dataset.updatedAt} />
+            </Flex>
+            <Flex my={2}>
+              <Text mr={1}>Created At:</Text>
+              <Time dateTime={dataset.createdAt} />
+            </Flex>
+          </Box>
+        )}
+        <Button onClick={onBack}>See all datasets</Button>
+      </Flex>
+      <Heading size="lg" mt={3} mb={2} fontWeight="normal">Upstream Events</Heading>
+      <Text>Whenever a DAG has updated this dataset.</Text>
+      <Table
+        data={data}
+        columns={columns}
+        manualPagination={{
+          offset,
+          setOffset,
+          totalEntries,
+        }}
+        manualSort={{
+          setSortBy,
+          sortBy,
+          initialSortBy: memoSort,
+        }}
+        pageSize={limit}
+        isLoading={isEventsLoading}
+      />
+    </Box>
+  );
+};
+
+export default DatasetDetails;
diff --git a/airflow/www/static/js/datasets/List.tsx b/airflow/www/static/js/datasets/List.tsx
index 56cc1211e3a9d..99c7e2aabec65 100644
--- a/airflow/www/static/js/datasets/List.tsx
+++ b/airflow/www/static/js/datasets/List.tsx
@@ -18,15 +18,27 @@
  */
 
 import React, { useMemo, useState } from 'react';
-import { Box, Code, Heading } from '@chakra-ui/react';
+import {
+  Box,
+  Code,
+  Heading,
+} from '@chakra-ui/react';
+import { snakeCase } from 'lodash';
+import type { Row, SortingRule } from 'react-table';
 
 import { useDatasets } from 'src/api';
 import Table from 'src/components/Table';
 import Time from 'src/components/Time';
-import { snakeCase } from 'lodash';
-import type { SortingRule } from 'react-table';
+import type { API } from 'src/types';
+
+interface Props {
+  onSelect: (datasetId: string) => void;
+}
 
-const DatasetsList = () => {
+const TimeCell = ({ cell: { value } }: any) => <Time dateTime={value} />;
+const CodeCell = ({ cell: { value } }: any) => <Code>{value}</Code>;
+
+const DatasetsList = ({ onSelect }: Props) => {
   const limit = 25;
   const [offset, setOffset] = useState(0);
   const [sortBy, setSortBy] = useState<SortingRule<object>[]>([]);
@@ -46,31 +58,33 @@ const DatasetsList = () => {
         Header: 'Extra',
         accessor: 'extra',
         disableSortBy: true,
+        Cell: CodeCell,
       },
       {
         Header: 'Created At',
         accessor: 'createdAt',
+        Cell: TimeCell,
       },
       {
         Header: 'Updated At',
         accessor: 'updatedAt',
+        Cell: TimeCell,
       },
     ],
     [],
   );
 
   const data = useMemo(
-    () => datasets.map((d) => ({
-      ...d,
-      extra: <Code>{d.extra}</Code>,
-      createdAt: <Time dateTime={d.createdAt} />,
-      updatedAt: <Time dateTime={d.updatedAt} />,
-    })),
+    () => datasets,
     [datasets],
   );
 
+  const onDatasetSelect = (row: Row<API.Dataset>) => {
+    onSelect(row.id);
+  };
+
   return (
-    <Box>
+    <Box maxWidth="1500px">
       <Heading mt={3} mb={2} fontWeight="normal">
         Datasets
       </Heading>
@@ -85,7 +99,11 @@ const DatasetsList = () => {
             totalEntries,
           }}
           pageSize={limit}
-          setSortBy={setSortBy}
+          manualSort={{
+            setSortBy,
+            sortBy,
+          }}
+          onRowClicked={onDatasetSelect}
         />
       </Box>
     </Box>
diff --git a/airflow/www/static/js/datasets/index.tsx b/airflow/www/static/js/datasets/index.tsx
index 76f303b46606d..3b26b442b8c84 100644
--- a/airflow/www/static/js/datasets/index.tsx
+++ b/airflow/www/static/js/datasets/index.tsx
@@ -22,10 +22,13 @@
 import React from 'react';
 import { createRoot } from 'react-dom/client';
 import createCache from '@emotion/cache';
+import { useSearchParams } from 'react-router-dom';
+import { Center } from '@chakra-ui/react';
 
 import App from 'src/App';
 
 import DatasetsList from './List';
+import DatasetDetails from './Details';
 
 // create shadowRoot
 const root = document.querySelector('#root');
@@ -36,13 +39,36 @@ const cache = createCache({
 });
 const mainElement = document.getElementById('react-container');
 
+const DATASET_ID = 'dataset_id';
+
+const Datasets = () => {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const onBack = () => {
+    searchParams.delete(DATASET_ID);
+    setSearchParams(searchParams);
+  };
+
+  const onSelect = (datasetId: string) => {
+    searchParams.set(DATASET_ID, datasetId);
+    setSearchParams(searchParams);
+  };
+
+  const datasetId = searchParams.get(DATASET_ID);
+  return (datasetId
+    ? <DatasetDetails datasetId={datasetId} onBack={onBack} />
+    : <DatasetsList onSelect={onSelect} />
+  );
+};
+
 if (mainElement) {
   shadowRoot?.appendChild(mainElement);
-
   const reactRoot = createRoot(mainElement);
   reactRoot.render(
     <App cache={cache}>
-      <DatasetsList />
+      <Center>
+        <Datasets />
+      </Center>
     </App>,
   );
 }
diff --git a/airflow/www/templates/airflow/datasets.html b/airflow/www/templates/airflow/datasets.html
index d40ae16993d7c..1cd81d547d5df 100644
--- a/airflow/www/templates/airflow/datasets.html
+++ b/airflow/www/templates/airflow/datasets.html
@@ -23,6 +23,7 @@
 {% block head_meta %}
   {{ super() }}
   <meta name="datasets_api" content="{{ url_for('/api/v1.airflow_api_connexion_endpoints_dataset_endpoint_get_datasets') }}">
+  <meta name="dataset_events_api" content="{{ url_for('/api/v1.airflow_api_connexion_endpoints_dataset_endpoint_get_dataset_events') }}">
 {% endblock %}
 
 {% block content %}
