This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch grid-list-mapped-tis
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 5c965d8be4d0a9ba598053d0ffd83fa66b61663c
Author: Brent Bovenzi <[email protected]>
AuthorDate: Fri Apr 1 10:29:45 2022 -0400

    Add table of mapped instances to grid view
---
 airflow/www/package.json                           |   1 +
 airflow/www/static/js/tree/Table.jsx               | 168 +++++++++++++++++++++
 airflow/www/static/js/tree/api/index.js            |   2 +
 .../www/static/js/tree/api/useMappedInstances.js   |  33 ++++
 .../js/tree/details/content/MappedInstances.jsx    | 136 +++++++++++++++++
 .../js/tree/details/content/taskInstance/index.jsx |   4 +
 airflow/www/yarn.lock                              |   5 +
 7 files changed, 349 insertions(+)

diff --git a/airflow/www/package.json b/airflow/www/package.json
index 9ee33c94..d15cd05 100644
--- a/airflow/www/package.json
+++ b/airflow/www/package.json
@@ -95,6 +95,7 @@
     "react-dom": "^17.0.2",
     "react-icons": "^4.3.1",
     "react-query": "^3.34.16",
+    "react-table": "^7.7.0",
     "redoc": "^2.0.0-rc.63",
     "url-search-params-polyfill": "^8.1.0"
   },
diff --git a/airflow/www/static/js/tree/Table.jsx 
b/airflow/www/static/js/tree/Table.jsx
new file mode 100644
index 0000000..2322f5a
--- /dev/null
+++ b/airflow/www/static/js/tree/Table.jsx
@@ -0,0 +1,168 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Custom wrapper of react-table using Chakra UI components
+*/
+
+import React, { useEffect } from 'react';
+import {
+  Flex,
+  Table as ChakraTable,
+  Thead,
+  Tbody,
+  Tr,
+  Th,
+  Td,
+  IconButton,
+  Text,
+  useColorModeValue,
+} from '@chakra-ui/react';
+import {
+  useTable, useSortBy, usePagination,
+} from 'react-table';
+import {
+  MdKeyboardArrowLeft, MdKeyboardArrowRight,
+} from 'react-icons/md';
+import {
+  TiArrowUnsorted, TiArrowSortedDown, TiArrowSortedUp,
+} from 'react-icons/ti';
+
+const Table = ({
+  data, columns, manualPagination, pageSize = 25, setSortBy,
+}) => {
+  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) : 
data.length;
+
+  const lowerCount = (offset || 0) + 1;
+  const upperCount = lowerCount + data.length - 1;
+
+  const {
+    getTableProps,
+    getTableBodyProps,
+    allColumns,
+    prepareRow,
+    page,
+    canPreviousPage,
+    canNextPage,
+    nextPage,
+    previousPage,
+    state: { pageIndex, sortBy },
+  } = useTable(
+    {
+      columns,
+      data,
+      pageCount,
+      manualPagination: !!manualPagination,
+      manualSortBy: !!setSortBy,
+      initialState: {
+        pageIndex: offset ? offset / pageSize : 0,
+        pageSize,
+      },
+    },
+    useSortBy,
+    usePagination,
+  );
+
+  const handleNext = () => {
+    nextPage();
+    if (setOffset) setOffset((pageIndex + 1) * pageSize);
+  };
+
+  const handlePrevious = () => {
+    previousPage();
+    if (setOffset) setOffset((pageIndex - 1 || 0) * pageSize);
+  };
+
+  useEffect(() => {
+    if (setSortBy) setSortBy(sortBy);
+  }, [sortBy, setSortBy]);
+
+  return (
+    <>
+      <ChakraTable {...getTableProps()}>
+        <Thead>
+          <Tr>
+            {allColumns.map((column) => (
+              <Th
+                {...column.getHeaderProps(column.getSortByToggleProps())}
+              >
+                {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" />)}
+              </Th>
+            ))}
+          </Tr>
+        </Thead>
+        <Tbody {...getTableBodyProps()}>
+          {!data.length && (
+          <Tr>
+            <Td colSpan={2}>No Data found.</Td>
+          </Tr>
+          )}
+          {page.map((row) => {
+            prepareRow(row);
+            return (
+              <Tr
+                {...row.getRowProps()}
+                _odd={{ backgroundColor: oddColor }}
+                _hover={{ backgroundColor: hoverColor }}
+              >
+                {row.cells.map((cell) => (
+                  <Td
+                    {...cell.getCellProps()}
+                    py={3}
+                  >
+                    {cell.render('Cell')}
+                  </Td>
+                ))}
+              </Tr>
+            );
+          })}
+        </Tbody>
+      </ChakraTable>
+      <Flex alignItems="center" justifyContent="flex-start" my={4}>
+        <IconButton variant="ghost" onClick={handlePrevious} 
disabled={!canPreviousPage} aria-label="Previous Page">
+          <MdKeyboardArrowLeft />
+        </IconButton>
+        <IconButton variant="ghost" onClick={handleNext} 
disabled={!canNextPage} aria-label="Next Page">
+          <MdKeyboardArrowRight />
+        </IconButton>
+        <Text>
+          {lowerCount}
+          -
+          {upperCount}
+          {' of '}
+          {totalEntries}
+        </Text>
+      </Flex>
+    </>
+  );
+};
+
+export default Table;
diff --git a/airflow/www/static/js/tree/api/index.js 
b/airflow/www/static/js/tree/api/index.js
index 1c169c9..aea1e91 100644
--- a/airflow/www/static/js/tree/api/index.js
+++ b/airflow/www/static/js/tree/api/index.js
@@ -33,6 +33,7 @@ import useMarkSuccessTask from './useMarkSuccessTask';
 import useExtraLinks from './useExtraLinks';
 import useConfirmMarkTask from './useConfirmMarkTask';
 import useTreeData from './useTreeData';
+import useMappedInstances from './useMappedInstances';
 
 axios.interceptors.response.use(
   (res) => (res.data ? camelcaseKeys(res.data, { deep: true }) : res),
@@ -54,4 +55,5 @@ export {
   useExtraLinks,
   useConfirmMarkTask,
   useTreeData,
+  useMappedInstances,
 };
diff --git a/airflow/www/static/js/tree/api/useMappedInstances.js 
b/airflow/www/static/js/tree/api/useMappedInstances.js
new file mode 100644
index 0000000..844a194
--- /dev/null
+++ b/airflow/www/static/js/tree/api/useMappedInstances.js
@@ -0,0 +1,33 @@
+/*!
+ * 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 from 'axios';
+import { useQuery } from 'react-query';
+
+export default function useMappedInstances({
+  dagId, runId, taskId, limit, offset, order,
+}) {
+  const orderParam = order && order !== 'map_index' ? { order_by: order } : {};
+  return useQuery(
+    ['mappedInstances', dagId, runId, taskId, offset, order],
+    () => 
axios.get(`/api/v1/dags/${dagId}/dagRuns/${runId}/taskInstances/${taskId}/listMapped`,
 {
+      params: { offset, limit, ...orderParam },
+    }),
+  );
+}
diff --git a/airflow/www/static/js/tree/details/content/MappedInstances.jsx 
b/airflow/www/static/js/tree/details/content/MappedInstances.jsx
new file mode 100644
index 0000000..f78e533
--- /dev/null
+++ b/airflow/www/static/js/tree/details/content/MappedInstances.jsx
@@ -0,0 +1,136 @@
+/*!
+ * 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, { useState, useMemo } from 'react';
+import {
+  Flex,
+  Text,
+  Box,
+  Link,
+  Button,
+} from '@chakra-ui/react';
+import { snakeCase } from 'lodash';
+
+import { formatDateTime, formatDuration } from '../../../datetime_utils';
+import { useMappedInstances } from '../../api';
+import { SimpleStatus } from '../../StatusBox';
+import Table from '../../Table';
+
+const MappedInstances = ({
+  dagId, runId, taskId,
+}) => {
+  const limit = 25;
+  const [offset, setOffset] = useState(0);
+  const [sortBy, setSortBy] = useState([]);
+
+  const sort = sortBy[0];
+
+  const order = sort && (sort.id === 'state' || sort.id === 'mapIndex') ? 
`${sort.desc ? '-' : ''}${snakeCase(sort.id)}` : '';
+
+  const {
+    data: { taskInstances, totalEntries } = { taskInstances: [], totalEntries: 
0 },
+  } = useMappedInstances({
+    dagId, runId, taskId, limit, offset, order,
+  });
+
+  const data = useMemo(
+    () => taskInstances.map((mi) => {
+      const params = new URLSearchParams({
+        dag_id: dagId,
+        task_id: mi.taskId,
+        execution_date: mi.executionDate,
+        map_index: mi.mapIndex,
+      }).toString();
+      const logLink = `/log?${params}`;
+      const detailsLink = `/task?${params}`;
+      return {
+        ...mi,
+        state: (
+          <Flex alignItems="center">
+            <SimpleStatus state={mi.state} mx={2} />
+            {mi.state || 'no status'}
+          </Flex>
+        ),
+        duration: formatDuration(mi.duration),
+        startDate: formatDateTime(mi.startDate),
+        endDate: formatDateTime(mi.endDate),
+        links: (
+          <Flex alignItems="center">
+            <Button as={Link} variant="outline" mr={1} colorScheme="blue" 
href={logLink}>Log</Button>
+            <Button as={Link} variant="outline" colorScheme="blue" 
href={detailsLink}>More Details</Button>
+          </Flex>
+        ),
+      };
+    }),
+    [dagId, taskInstances],
+  );
+
+  const columns = useMemo(
+    () => [
+      {
+        Header: 'Map Index',
+        accessor: 'mapIndex',
+      },
+      {
+        Header: 'State',
+        accessor: 'state',
+      },
+      {
+        Header: 'Duration',
+        accessor: 'duration',
+        disableSortBy: true,
+      },
+      {
+        Header: 'Start Date',
+        accessor: 'startDate',
+        disableSortBy: true,
+      },
+      {
+        Header: 'End Date',
+        accessor: 'endDate',
+        disableSortBy: true,
+      },
+      {
+        disableSortBy: true,
+        accessor: 'links',
+      },
+    ],
+    [],
+  );
+
+  return (
+    <Box>
+      <br />
+      <Text as="strong">Mapped Instances</Text>
+      <Table
+        data={data}
+        columns={columns}
+        manualPagination={{
+          offset,
+          setOffset,
+          totalEntries,
+        }}
+        pageSize={limit}
+        setSortBy={setSortBy}
+      />
+    </Box>
+  );
+};
+
+export default MappedInstances;
diff --git a/airflow/www/static/js/tree/details/content/taskInstance/index.jsx 
b/airflow/www/static/js/tree/details/content/taskInstance/index.jsx
index 8457d99..ed970f4 100644
--- a/airflow/www/static/js/tree/details/content/taskInstance/index.jsx
+++ b/airflow/www/static/js/tree/details/content/taskInstance/index.jsx
@@ -35,6 +35,7 @@ import TaskNav from './Nav';
 import Details from './Details';
 
 import { useTreeData } from '../../../api';
+import MappedInstances from '../MappedInstances';
 
 const getTask = ({ taskId, runId, task }) => {
   if (task.id === taskId) return task;
@@ -100,6 +101,9 @@ const TaskInstance = ({ taskId, runId }) => {
         executionDate={executionDate}
         extraLinks={task.extraLinks}
       />
+      {task.isMapped && (
+        <MappedInstances dagId={dagId} runId={runId} taskId={taskId} />
+      )}
     </Box>
   );
 };
diff --git a/airflow/www/yarn.lock b/airflow/www/yarn.lock
index 97cfb66..9804dc1 100644
--- a/airflow/www/yarn.lock
+++ b/airflow/www/yarn.lock
@@ -9838,6 +9838,11 @@ react-style-singleton@^2.1.0:
     invariant "^2.2.4"
     tslib "^1.0.0"
 
+react-table@^7.7.0:
+  version "7.7.0"
+  resolved 
"https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912";
+  integrity 
sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==
+
 react-tabs@^3.2.2:
   version "3.2.2"
   resolved 
"https://registry.yarnpkg.com/react-tabs/-/react-tabs-3.2.2.tgz#07bdc3cdb17bdffedd02627f32a93cd4b3d6e4d0";

Reply via email to