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 436c17c655 List Mapped Instances in Task Details Panel (#22691)
436c17c655 is described below

commit 436c17c655494eff5724df98d1a231ffa2142253
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Apr 4 10:33:10 2022 -0500

    List Mapped Instances in Task Details Panel (#22691)
    
    * Add table of mapped instances to grid view
    
    * Autorefresh, table width, remove extraneous code
    
    * Switch to icon buttons
    
    * Fix mapped instances url
    
    * Only show dates when they exist and are final
---
 airflow/www/package.json                           |   1 +
 airflow/www/static/js/tree/Table.jsx               | 176 +++++++++++++++++++++
 airflow/www/static/js/tree/api/index.js            |   2 +
 .../www/static/js/tree/api/useMappedInstances.js   |  46 ++++++
 .../js/tree/details/content/dagRun/index.jsx       |   4 +
 .../tree/details/content/taskInstance/Details.jsx  |   5 +
 .../content/taskInstance/MappedInstances.jsx       | 152 ++++++++++++++++++
 .../js/tree/details/content/taskInstance/Nav.jsx   |  11 --
 .../js/tree/details/content/taskInstance/index.jsx |   4 +
 airflow/www/static/js/tree/details/index.jsx       |  15 +-
 airflow/www/templates/airflow/dag.html             |   1 +
 airflow/www/yarn.lock                              |   5 +
 12 files changed, 400 insertions(+), 22 deletions(-)

diff --git a/airflow/www/package.json b/airflow/www/package.json
index 9ee33c94cd..d15cd05d9e 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 0000000000..06eb84cad4
--- /dev/null
+++ b/airflow/www/static/js/tree/Table.jsx
@@ -0,0 +1,176 @@
+/*!
+ * 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, isLoading = false,
+}) => {
+  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 && !isLoading && (
+          <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"
+          icon={<MdKeyboardArrowLeft />}
+        />
+        <IconButton
+          variant="ghost"
+          onClick={handleNext}
+          disabled={!canNextPage}
+          aria-label="Next Page"
+          icon={<MdKeyboardArrowRight />}
+        />
+        <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 1c169c91f5..aea1e918f8 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 0000000000..35eb1decb6
--- /dev/null
+++ b/airflow/www/static/js/tree/api/useMappedInstances.js
@@ -0,0 +1,46 @@
+/*!
+ * 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 autoRefreshInterval */
+
+import axios from 'axios';
+import { useQuery } from 'react-query';
+
+import { getMetaValue } from '../../utils';
+import { useAutoRefresh } from '../context/autorefresh';
+
+const mappedInstancesUrl = getMetaValue('mapped_instances_api');
+
+export default function useMappedInstances({
+  dagId, runId, taskId, limit, offset, order,
+}) {
+  const url = mappedInstancesUrl.replace('_DAG_RUN_ID_', 
runId).replace('_TASK_ID_', taskId);
+  const orderParam = order && order !== 'map_index' ? { order_by: order } : {};
+  const { isRefreshOn } = useAutoRefresh();
+  return useQuery(
+    ['mappedInstances', dagId, runId, taskId, offset, order],
+    () => axios.get(url, {
+      params: { offset, limit, ...orderParam },
+    }),
+    {
+      keepPreviousData: true,
+      refetchInterval: isRefreshOn && autoRefreshInterval * 1000,
+    },
+  );
+}
diff --git a/airflow/www/static/js/tree/details/content/dagRun/index.jsx 
b/airflow/www/static/js/tree/details/content/dagRun/index.jsx
index 8a8b3bd705..68e6d6526a 100644
--- a/airflow/www/static/js/tree/details/content/dagRun/index.jsx
+++ b/airflow/www/static/js/tree/details/content/dagRun/index.jsx
@@ -126,16 +126,20 @@ const DagRun = ({ runId }) => {
         <Time dateTime={dataIntervalEnd} />
       </Text>
       <br />
+      {startDate && (
       <Text>
         Started:
         {' '}
         <Time dateTime={startDate} />
       </Text>
+      )}
+      {endDate && (
       <Text>
         Ended:
         {' '}
         <Time dateTime={endDate} />
       </Text>
+      )}
     </Box>
   );
 };
diff --git 
a/airflow/www/static/js/tree/details/content/taskInstance/Details.jsx 
b/airflow/www/static/js/tree/details/content/taskInstance/Details.jsx
index f84996a09f..44294f51f7 100644
--- a/airflow/www/static/js/tree/details/content/taskInstance/Details.jsx
+++ b/airflow/www/static/js/tree/details/content/taskInstance/Details.jsx
@@ -89,6 +89,7 @@ const Details = ({ instance, task }) => {
   }
 
   const taskIdTitle = isGroup ? 'Task Group Id: ' : 'Task Id: ';
+  const isStateFinal = ['success', 'failed', 'upstream_failed', 
'skipped'].includes(state);
 
   return (
     <Flex flexWrap="wrap" justifyContent="space-between">
@@ -144,16 +145,20 @@ const Details = ({ instance, task }) => {
         </Text>
       </Box>
       <Box>
+        {startDate && (
         <Text>
           Started:
           {' '}
           <Time dateTime={startDate} />
         </Text>
+        )}
+        {endDate && isStateFinal && (
         <Text>
           Ended:
           {' '}
           <Time dateTime={endDate} />
         </Text>
+        )}
       </Box>
     </Flex>
   );
diff --git 
a/airflow/www/static/js/tree/details/content/taskInstance/MappedInstances.jsx 
b/airflow/www/static/js/tree/details/content/taskInstance/MappedInstances.jsx
new file mode 100644
index 0000000000..b815f0987f
--- /dev/null
+++ 
b/airflow/www/static/js/tree/details/content/taskInstance/MappedInstances.jsx
@@ -0,0 +1,152 @@
+/*!
+ * 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,
+  IconButton,
+} from '@chakra-ui/react';
+import { snakeCase } from 'lodash';
+import { FaMicroscope } from 'react-icons/fa';
+import { GiLog } from 'react-icons/gi';
+import { HiTemplate } from 'react-icons/hi';
+
+import { getMetaValue } from '../../../../utils';
+import { formatDateTime, formatDuration } from '../../../../datetime_utils';
+import { useMappedInstances } from '../../../api';
+import { SimpleStatus } from '../../../StatusBox';
+import Table from '../../../Table';
+
+const renderedTemplatesUrl = getMetaValue('rendered_templates_url');
+const logUrl = getMetaValue('log_url');
+const taskUrl = getMetaValue('task_url');
+
+const IconLink = (props) => (
+  <IconButton as={Link} variant="outline" colorScheme="blue" {...props} />
+);
+
+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 },
+    isLoading,
+  } = 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 detailsLink = `${taskUrl}&${params}`;
+      const renderedLink = `${renderedTemplatesUrl}&${params}`;
+      const logLink = `${logUrl}&${params}`;
+      return {
+        ...mi,
+        state: (
+          <Flex alignItems="center">
+            <SimpleStatus state={mi.state} mx={2} />
+            {mi.state || 'no status'}
+          </Flex>
+        ),
+        duration: mi.duration && formatDuration(mi.duration),
+        startDate: mi.startDate && formatDateTime(mi.startDate),
+        endDate: mi.endDate && formatDateTime(mi.endDate),
+        links: (
+          <Flex alignItems="center">
+            <IconLink mr={1} title="Rendered Templates" aria-label="Rendered 
Templates" icon={<HiTemplate />} href={renderedLink} />
+            <IconLink mr={1} title="Log" aria-label="Log" icon={<GiLog />} 
href={logLink} />
+            <IconLink title="Details" aria-label="Details" icon={<FaMicroscope 
/>} href={detailsLink} />
+          </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}
+        isLoading={isLoading}
+      />
+    </Box>
+  );
+};
+
+export default MappedInstances;
diff --git a/airflow/www/static/js/tree/details/content/taskInstance/Nav.jsx 
b/airflow/www/static/js/tree/details/content/taskInstance/Nav.jsx
index 6fc8eb6055..d0841d0119 100644
--- a/airflow/www/static/js/tree/details/content/taskInstance/Nav.jsx
+++ b/airflow/www/static/js/tree/details/content/taskInstance/Nav.jsx
@@ -44,7 +44,6 @@ const Nav = ({ instance, isMapped }) => {
   const {
     taskId,
     dagId,
-    runId,
     operator,
     executionDate,
   } = instance;
@@ -62,12 +61,6 @@ const Nav = ({ instance, isMapped }) => {
     _flt_3_task_id: taskId,
     _oc_TaskInstanceModelView: 'dag_run.execution_date',
   });
-  const mapParams = new URLSearchParams({
-    _flt_3_dag_id: dagId,
-    _flt_3_task_id: taskId,
-    _flt_3_run_id: runId,
-    _oc_TaskInstanceModelView: 'map_index',
-  });
   const subDagParams = new URLSearchParams({
     execution_date: executionDate,
   }).toString();
@@ -79,7 +72,6 @@ const Nav = ({ instance, isMapped }) => {
   }).toString();
 
   const allInstancesLink = `${taskInstancesUrl}?${listParams.toString()}`;
-  const mappedInstancesLink = `${taskInstancesUrl}?${mapParams.toString()}`;
 
   const filterUpstreamLink = appendSearchParams(gridUrlNoRoot, filterParams);
   const subDagLink = appendSearchParams(gridUrl.replace(dagId, 
`${dagId}.${taskId}`), subDagParams);
@@ -103,9 +95,6 @@ const Nav = ({ instance, isMapped }) => {
           <LinkButton href={logLink}>Log</LinkButton>
         </>
         )}
-        {isMapped && (
-        <LinkButton href={mappedInstancesLink} title="Show the mapped 
instances for this DAG run">Mapped Instances</LinkButton>
-        )}
         <LinkButton href={allInstancesLink} title="View all instances across 
all DAG runs">All Instances</LinkButton>
         <LinkButton href={filterUpstreamLink}>Filter Upstream</LinkButton>
       </Flex>
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 8457d99ca1..1e787c7aba 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/static/js/tree/details/index.jsx 
b/airflow/www/static/js/tree/details/index.jsx
index 1bdf4e2353..ffe8b0ff74 100644
--- a/airflow/www/static/js/tree/details/index.jsx
+++ b/airflow/www/static/js/tree/details/index.jsx
@@ -30,28 +30,21 @@ import DagRunContent from './content/dagRun';
 import DagContent from './content/Dag';
 import { useSelection } from '../context/selection';
 
-const Details = ({ isRefreshOn, onToggleRefresh }) => {
+const Details = () => {
   const { selected } = useSelection();
   return (
-    <Flex borderLeftWidth="1px" flexDirection="column" p={3} flexGrow={1} 
maxWidth="600px">
+    <Flex borderLeftWidth="1px" flexDirection="column" pl={3} mr={3} 
flexGrow={1} maxWidth="750px">
       <Header />
       <Divider my={2} />
-      <Box minWidth="500px">
-        {/* TODO: get full instance data from the API */}
+      <Box minWidth="750px">
         {!selected.runId && !selected.taskId && <DagContent />}
         {selected.runId && !selected.taskId && (
-          <DagRunContent
-            runId={selected.runId}
-            isRefreshOn={isRefreshOn}
-            onToggleRefresh={onToggleRefresh}
-          />
+          <DagRunContent runId={selected.runId} />
         )}
         {selected.taskId && (
         <TaskInstanceContent
           runId={selected.runId}
           taskId={selected.taskId}
-          isRefreshOn={isRefreshOn}
-          onToggleRefresh={onToggleRefresh}
         />
         )}
       </Box>
diff --git a/airflow/www/templates/airflow/dag.html 
b/airflow/www/templates/airflow/dag.html
index ec4f064987..d77d7f2bc0 100644
--- a/airflow/www/templates/airflow/dag.html
+++ b/airflow/www/templates/airflow/dag.html
@@ -67,6 +67,7 @@
   <meta name="task_instances_url" content="{{ 
url_for('TaskInstanceModelView.list') }}">
   <meta name="dag_details_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_dag_endpoint_get_dag_details', 
dag_id=dag.dag_id) }}">
   <meta name="tasks_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_task_endpoint_get_tasks', 
dag_id=dag.dag_id) }}">
+  <meta name="mapped_instances_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_get_mapped_task_instances',
 dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_') }}">
   <!-- End Urls -->
   <meta name="is_paused" content="{{ dag_is_paused }}">
   <meta name="csrf_token" content="{{ csrf_token() }}">
diff --git a/airflow/www/yarn.lock b/airflow/www/yarn.lock
index 97cfb667ec..9804dc180b 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