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 770ee07212 Add Task Logs to Grid details panel (#24249)
770ee07212 is described below

commit 770ee0721263e108c7c74218fd583fad415e75c1
Author: pierrejeambrun <[email protected]>
AuthorDate: Mon Jun 13 03:03:42 2022 +0800

    Add Task Logs to Grid details panel (#24249)
    
    * WIP
    
    * Add Common LogLink component.
    
    * Split details in two columns.
    
    * Retrieve task log url from metadata.
    
    * Checkbox for requesting full logs, add tabs.
    
    * Persist tab preference into the local storage.
    
    * Task group tab fallback fix.
    
    * Simple LinkButton test for shared component.
    
    * Use codeblock component and auto scroll to bottom
    
    * Add checkbox for line wrapping toggle
    
    * Remove animation scroll into view, fix logs mapped tasks.
    
    * Add more tests.
    
    * Fix replace issue for certain tasks.
    
    * Add LogLink Internal test.
---
 airflow/www/static/js/grid/api/useTaskLog.js       |  41 +++++
 .../www/static/js/grid/components/LinkButton.jsx   |  28 +++
 .../static/js/grid/components/LinkButton.test.jsx  |  38 ++++
 .../js/grid/details/content/taskInstance/Logs.jsx  | 121 -------------
 .../details/content/taskInstance/Logs/LogLink.jsx  |  52 ++++++
 .../content/taskInstance/Logs/LogLink.test.jsx     |  67 ++++++++
 .../details/content/taskInstance/Logs/index.jsx    | 191 +++++++++++++++++++++
 .../content/taskInstance/Logs/index.test.jsx       | 143 +++++++++++++++
 .../js/grid/details/content/taskInstance/Nav.jsx   |  10 +-
 .../js/grid/details/content/taskInstance/index.jsx | 174 +++++++++++++------
 airflow/www/templates/airflow/dag.html             |   1 +
 11 files changed, 680 insertions(+), 186 deletions(-)

diff --git a/airflow/www/static/js/grid/api/useTaskLog.js 
b/airflow/www/static/js/grid/api/useTaskLog.js
new file mode 100644
index 0000000000..40041d1c33
--- /dev/null
+++ b/airflow/www/static/js/grid/api/useTaskLog.js
@@ -0,0 +1,41 @@
+/*!
+ * 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';
+import { getMetaValue } from '../../utils';
+
+const taskLogApi = getMetaValue('task_log_api');
+
+const useTaskLog = ({
+  dagId, dagRunId, taskId, taskTryNumber, fullContent, enabled,
+}) => {
+  const url = taskLogApi.replace('_DAG_RUN_ID_', 
dagRunId).replace('_TASK_ID_', taskId).replace(/-1$/, taskTryNumber);
+
+  return useQuery(
+    ['taskLogs', dagId, dagRunId, taskId, taskTryNumber, fullContent],
+    () => axios.get(url, { headers: { Accept: 'text/plain' }, params: { 
full_content: fullContent } }),
+    {
+      placeholderData: '',
+      enabled,
+    },
+  );
+};
+
+export default useTaskLog;
diff --git a/airflow/www/static/js/grid/components/LinkButton.jsx 
b/airflow/www/static/js/grid/components/LinkButton.jsx
new file mode 100644
index 0000000000..00cff8d188
--- /dev/null
+++ b/airflow/www/static/js/grid/components/LinkButton.jsx
@@ -0,0 +1,28 @@
+/*!
+ * 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 {
+  Button,
+  Link,
+} from '@chakra-ui/react';
+
+const LinkButton = ({ children, ...rest }) => (<Button as={Link} 
variant="ghost" colorScheme="blue" {...rest}>{children}</Button>);
+
+export default LinkButton;
diff --git a/airflow/www/static/js/grid/components/LinkButton.test.jsx 
b/airflow/www/static/js/grid/components/LinkButton.test.jsx
new file mode 100644
index 0000000000..0fc9181a58
--- /dev/null
+++ b/airflow/www/static/js/grid/components/LinkButton.test.jsx
@@ -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.
+ */
+
+/* global describe, test, expect */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import LinkButton from './LinkButton';
+
+describe('Test LinkButton Component.', () => {
+  test('LinkButton should be rendered as a link.', () => {
+    const { getByText, container } = render(
+      <LinkButton>
+        <div>The link</div>
+      </LinkButton>,
+    );
+
+    expect(getByText('The link')).toBeDefined();
+    expect(container.querySelector('a')).not.toBeNull();
+  });
+});
diff --git a/airflow/www/static/js/grid/details/content/taskInstance/Logs.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs.jsx
deleted file mode 100644
index 5262188b07..0000000000
--- a/airflow/www/static/js/grid/details/content/taskInstance/Logs.jsx
+++ /dev/null
@@ -1,121 +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 {
-  Text,
-  Box,
-  Button,
-  Flex,
-  Link,
-  Divider,
-} from '@chakra-ui/react';
-
-import { getMetaValue } from '../../../../utils';
-
-const logsWithMetadataUrl = getMetaValue('logs_with_metadata_url');
-const showExternalLogRedirect = getMetaValue('show_external_log_redirect') === 
'True';
-const externalLogUrl = getMetaValue('external_log_url');
-const externalLogName = getMetaValue('external_log_name');
-
-const LinkButton = ({ children, ...rest }) => (<Button as={Link} 
variant="ghost" colorScheme="blue" {...rest}>{children}</Button>);
-
-const Logs = ({
-  dagId,
-  taskId,
-  executionDate,
-  tryNumber,
-}) => {
-  const externalLogs = [];
-
-  const logAttempts = [...Array(tryNumber + 1 || 0)].map((_, index) => {
-    if (index === 0 && tryNumber < 2) return null;
-
-    const isExternal = index !== 0 && showExternalLogRedirect;
-
-    if (isExternal) {
-      const fullExternalUrl = `${externalLogUrl
-      }?dag_id=${encodeURIComponent(dagId)
-      }&task_id=${encodeURIComponent(taskId)
-      }&execution_date=${encodeURIComponent(executionDate)
-      }&try_number=${index}`;
-      externalLogs.push(
-        <LinkButton
-          // eslint-disable-next-line react/no-array-index-key
-          key={index}
-          href={fullExternalUrl}
-          target="_blank"
-        >
-          {index}
-        </LinkButton>,
-      );
-    }
-
-    const fullMetadataUrl = `${logsWithMetadataUrl
-    }?dag_id=${encodeURIComponent(dagId)
-    }&task_id=${encodeURIComponent(taskId)
-    }&execution_date=${encodeURIComponent(executionDate)
-    }&format=file${index > 0 && `&try_number=${index}`}`;
-
-    return (
-      <LinkButton
-        // eslint-disable-next-line react/no-array-index-key
-        key={index}
-        href={fullMetadataUrl}
-      >
-        {index === 0 ? 'All' : index}
-      </LinkButton>
-    );
-  });
-
-  return (
-    <>
-      {tryNumber > 0 && (
-      <>
-        <Box>
-          <Text>Download Log (by attempts):</Text>
-          <Flex flexWrap="wrap">
-            {logAttempts}
-          </Flex>
-        </Box>
-        <Divider my={2} />
-      </>
-      )}
-      {externalLogName && externalLogs.length > 0 && (
-      <>
-        <Box>
-          <Text>
-            View Logs in
-            {' '}
-            {externalLogName}
-            {' '}
-            (by attempts):
-          </Text>
-          <Flex flexWrap="wrap">
-            {externalLogs}
-          </Flex>
-        </Box>
-        <Divider my={2} />
-      </>
-      )}
-    </>
-  );
-};
-
-export default Logs;
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/Logs/LogLink.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/LogLink.jsx
new file mode 100644
index 0000000000..2e0f13f88d
--- /dev/null
+++ b/airflow/www/static/js/grid/details/content/taskInstance/Logs/LogLink.jsx
@@ -0,0 +1,52 @@
+/*!
+ * 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 { getMetaValue } from '../../../../../utils';
+import LinkButton from '../../../../components/LinkButton';
+
+const logsWithMetadataUrl = getMetaValue('logs_with_metadata_url');
+const externalLogUrl = getMetaValue('external_log_url');
+
+const LogLink = ({
+  dagId, taskId, executionDate, isInternal, tryNumber,
+}) => {
+  let fullMetadataUrl = `${isInternal ? logsWithMetadataUrl : externalLogUrl
+  }?dag_id=${encodeURIComponent(dagId)
+  }&task_id=${encodeURIComponent(taskId)
+  }&execution_date=${encodeURIComponent(executionDate)
+  }`;
+
+  if (isInternal) {
+    fullMetadataUrl += `&format=file${tryNumber > 0 && 
`&try_number=${tryNumber}`}`;
+  } else {
+    fullMetadataUrl += `&try_number=${tryNumber}`;
+  }
+  return (
+    <LinkButton
+      href={fullMetadataUrl}
+      target={isInternal ? undefined : '_blank'}
+    >
+      {isInternal ? 'Download' : tryNumber}
+    </LinkButton>
+  );
+};
+
+export default LogLink;
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/Logs/LogLink.test.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/LogLink.test.jsx
new file mode 100644
index 0000000000..9a47fe3b85
--- /dev/null
+++ 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/LogLink.test.jsx
@@ -0,0 +1,67 @@
+/*!
+ * 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 { render } from '@testing-library/react';
+import LogLink from './LogLink';
+
+describe('Test LogLink Component.', () => {
+  test('Internal Link', () => {
+    const tryNumber = 1;
+    const { getByText, container } = render(
+      <LogLink
+        tryNumber={tryNumber}
+        dagId="dummyDagId"
+        taskId="dummyTaskId"
+        executionDate="2020:01:01T01:00+00:00"
+        isInternal
+      />,
+    );
+
+    expect(getByText('Download')).toBeDefined();
+    const linkElement = container.querySelector('a');
+    expect(linkElement).toBeDefined();
+    expect(linkElement).not.toHaveAttribute('target');
+    expect(linkElement.href.includes(
+      
`?dag_id=dummyDagId&task_id=dummyTaskId&execution_date=2020%3A01%3A01T01%3A00%2B00%3A00&format=file&try_number=${tryNumber}`,
+    )).toBeTruthy();
+  });
+
+  test('External Link', () => {
+    const tryNumber = 1;
+    const { getByText, container } = render(
+      <LogLink
+        tryNumber={tryNumber}
+        dagId="dummyDagId"
+        taskId="dummyTaskId"
+        executionDate="2020:01:01T01:00+00:00"
+      />,
+    );
+
+    expect(getByText(tryNumber)).toBeDefined();
+    const linkElement = container.querySelector('a');
+    expect(linkElement).toBeDefined();
+    expect(linkElement).toHaveAttribute('target', '_blank');
+    expect(linkElement.href.includes(
+      
`?dag_id=dummyDagId&task_id=dummyTaskId&execution_date=2020%3A01%3A01T01%3A00%2B00%3A00&try_number=${tryNumber}`,
+    )).toBeTruthy();
+  });
+});
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/Logs/index.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/index.jsx
new file mode 100644
index 0000000000..8479593715
--- /dev/null
+++ b/airflow/www/static/js/grid/details/content/taskInstance/Logs/index.jsx
@@ -0,0 +1,191 @@
+/*!
+ * 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, { useRef, useState, useEffect } from 'react';
+import {
+  Text,
+  Box,
+  Flex,
+  Divider,
+  Code,
+  Button,
+  Checkbox,
+} from '@chakra-ui/react';
+
+import { getMetaValue } from '../../../../../utils';
+import LogLink from './LogLink';
+import useTaskLog from '../../../../api/useTaskLog';
+import LinkButton from '../../../../components/LinkButton';
+
+const showExternalLogRedirect = getMetaValue('show_external_log_redirect') === 
'True';
+const externalLogName = getMetaValue('external_log_name');
+const logUrl = getMetaValue('log_url');
+
+const getLinkIndexes = (tryNumber) => {
+  const internalIndexes = [];
+  const externalIndexes = [];
+
+  [...Array(tryNumber + 1 || 0)].forEach((_, index) => {
+    if (index === 0 && tryNumber < 2) return;
+    const isExternal = index !== 0 && showExternalLogRedirect;
+    if (isExternal) {
+      externalIndexes.push(index);
+    } else {
+      internalIndexes.push(index);
+    }
+  });
+
+  return [internalIndexes, externalIndexes];
+};
+
+const Logs = ({
+  dagId,
+  dagRunId,
+  taskId,
+  executionDate,
+  tryNumber,
+}) => {
+  const [internalIndexes, externalIndexes] = getLinkIndexes(tryNumber);
+  const [selectedAttempt, setSelectedAttempt] = useState(1);
+  const [shouldRequestFullContent, setShouldRequestFullContent] = 
useState(false);
+  const [wrap, setWrap] = useState(false);
+  const { data, isSuccess } = useTaskLog({
+    dagId,
+    dagRunId,
+    taskId,
+    taskTryNumber: selectedAttempt,
+    fullContent: shouldRequestFullContent,
+  });
+
+  const codeBlockBottomDiv = useRef(null);
+
+  useEffect(() => {
+    if (codeBlockBottomDiv.current) {
+      codeBlockBottomDiv.current.scrollIntoView();
+    }
+  }, [wrap, data]);
+
+  const params = new URLSearchParams({
+    task_id: taskId,
+    execution_date: executionDate,
+  }).toString();
+
+  return (
+    <>
+      {tryNumber > 0 && (
+      <>
+        <Text as="span"> (by attempts)</Text>
+        <Box>
+          <Flex my={1} justifyContent="space-between">
+            <Flex flexWrap="wrap">
+              {internalIndexes.map((index) => (
+                <Button
+                  key={index}
+                  variant="ghost"
+                  colorScheme="blue"
+                  onClick={() => setSelectedAttempt(index)}
+                  data-testid={`log-attempt-select-button-${index}`}
+                >
+                  {index}
+                </Button>
+              ))}
+            </Flex>
+            <Flex>
+              <Checkbox
+                onChange={() => setWrap((previousState) => !previousState)}
+                px={4}
+              >
+                <Text as="strong">Wrap</Text>
+              </Checkbox>
+              <Checkbox
+                onChange={() => setShouldRequestFullContent((previousState) => 
!previousState)}
+                px={4}
+                data-testid="full-content-checkbox"
+              >
+                <Text as="strong">Full Logs</Text>
+              </Checkbox>
+              <LogLink
+                index={selectedAttempt}
+                dagId={dagId}
+                taskId={taskId}
+                executionDate={executionDate}
+                isInternal
+              />
+              <LinkButton
+                href={`${logUrl}&${params}`}
+              >
+                See More
+              </LinkButton>
+            </Flex>
+          </Flex>
+        </Box>
+        {
+          isSuccess && (
+            <Code
+              height={350}
+              overflowY="scroll"
+              p={3}
+              pb={0}
+              display="block"
+              whiteSpace={wrap ? 'pre-wrap' : 'pre'}
+              border="1px solid"
+              borderRadius={3}
+              borderColor="blue.500"
+            >
+              {data}
+              <div ref={codeBlockBottomDiv} />
+            </Code>
+          )
+        }
+      </>
+      )}
+      {externalLogName && externalIndexes.length > 0 && (
+      <>
+        <Box>
+          <Text>
+            View Logs in
+            {' '}
+            {externalLogName}
+            {' '}
+            (by attempts):
+          </Text>
+          <Flex flexWrap="wrap">
+            {
+              externalIndexes.map(
+                (index) => (
+                  <LogLink
+                    key={index}
+                    dagId={dagId}
+                    taskId={taskId}
+                    executionDate={executionDate}
+                    tryNumber={index}
+                  />
+                ),
+              )
+            }
+          </Flex>
+        </Box>
+        <Divider my={2} />
+      </>
+      )}
+    </>
+  );
+};
+
+export default Logs;
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/Logs/index.test.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/index.test.jsx
new file mode 100644
index 0000000000..47af93b1e1
--- /dev/null
+++ 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/index.test.jsx
@@ -0,0 +1,143 @@
+/*!
+ * 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 jest, describe, test, expect, beforeEach, window */
+
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import Logs from './index';
+import * as useTaskLogModule from '../../../../api/useTaskLog';
+
+const mockTaskLog = `
+5d28cfda3219
+*** Reading local file: 
/root/airflow/logs/dag_id=test_ui_grid/run_id=scheduled__2022-06-03T00:00:00+00:00/task_id=section_1.get_entry_group/attempt=1.log
+[2022-06-04 00:00:01,901] {taskinstance.py:1132} INFO - Dependencies all met 
for <TaskInstance: test_ui_grid.section_1.get_entry_group 
scheduled__2022-06-03T00:00:00+00:00 [queued]>
+[2022-06-04 00:00:01,906] {taskinstance.py:1132} INFO - Dependencies all met 
for <TaskInstance: test_ui_grid.section_1.get_entry_group 
scheduled__2022-06-03T00:00:00+00:00 [queued]>
+[2022-06-04 00:00:01,906] {taskinstance.py:1329} INFO -
+--------------------------------------------------------------------------------
+[2022-06-04 00:00:01,906] {taskinstance.py:1330} INFO - Starting attempt 1 of 1
+[2022-06-04 00:00:01,906] {taskinstance.py:1331} INFO -
+--------------------------------------------------------------------------------
+[2022-06-04 00:00:01,916] {taskinstance.py:1350} INFO - Executing 
<Task(BashOperator): section_1.get_entry_group> on 2022-06-03 00:00:00+00:00
+[2022-06-04 00:00:01,919] {standard_task_runner.py:52} INFO - Started process 
41646 to run task
+[2022-06-04 00:00:01,920] {standard_task_runner.py:80} INFO - Running: ['***', 
'tasks', 'run', 'test_ui_grid', 'section_1.get_entry_group', 
'scheduled__2022-06-03T00:00:00+00:00', '--job-id', '1626', '--raw', 
'--subdir', 'DAGS_FOLDER/test_ui_grid.py', '--cfg-path', '/tmp/tmpte7k80ur']
+[2022-06-04 00:00:01,921] {standard_task_runner.py:81} INFO - Job 1626: 
Subtask section_1.get_entry_group
+[2022-06-04 00:00:01,921] {dagbag.py:507} INFO - Filling up the DagBag from 
/files/dags/test_ui_grid.py
+[2022-06-04 00:00:01,964] {task_command.py:377} INFO - Running <TaskInstance: 
test_ui_grid.section_1.get_entry_group scheduled__2022-06-03T00:00:00+00:00 
[running]> on host 5d28cfda3219
+[2022-06-04 00:00:02,010] {taskinstance.py:1548} INFO - Exporting the 
following env vars:
+AIRFLOW_CTX_DAG_OWNER=***
+AIRFLOW_CTX_DAG_ID=test_ui_grid
+`;
+
+let useTaskLogMock;
+
+describe('Test Logs Component.', () => {
+  beforeEach(() => {
+    useTaskLogMock = jest.spyOn(useTaskLogModule, 
'default').mockImplementation(() => ({
+      data: mockTaskLog,
+      isSuccess: true,
+    }));
+    window.HTMLElement.prototype.scrollIntoView = jest.fn();
+  });
+
+  test('Test Logs Content', () => {
+    const tryNumber = 2;
+    const { getByText } = render(
+      <Logs
+        dagId="dummyDagId"
+        dagRunId="dummyDagRunId"
+        taskId="dummyTaskId"
+        executionDate="2020:01:01T01:00+00:00"
+        tryNumber={tryNumber}
+      />,
+    );
+    expect(getByText('[2022-06-04 00:00:01,906] {taskinstance.py:1330} INFO 
-', { exact: false })).toBeDefined();
+    expect(getByText('[2022-06-04 00:00:01,921] {standard_task_runner.py:81} 
INFO - Job 1626: Subtask section_1.get_entry_group',
+      { exact: false })).toBeDefined();
+    expect(getByText('AIRFLOW_CTX_DAG_ID=test_ui_grid', { exact: false 
})).toBeDefined();
+  });
+
+  test('Test Logs Attempt Select Button', () => {
+    const tryNumber = 2;
+    const { getByText, getByTestId } = render(
+      <Logs
+        dagId="dummyDagId"
+        dagRunId="dummyDagRunId"
+        taskId="dummyTaskId"
+        executionDate="2020:01:01T01:00+00:00"
+        tryNumber={tryNumber}
+      />,
+    );
+    // Internal Log Attempt buttons.
+    expect(getByText('1')).toBeDefined();
+    expect(getByText('2')).toBeDefined();
+
+    expect(getByText('Download')).toBeDefined();
+
+    expect(useTaskLogMock).toHaveBeenLastCalledWith({
+      dagId: 'dummyDagId',
+      dagRunId: 'dummyDagRunId',
+      fullContent: false,
+      taskId: 'dummyTaskId',
+      taskTryNumber: 1,
+    });
+    const attemptButton2 = getByTestId('log-attempt-select-button-2');
+
+    fireEvent.click(attemptButton2);
+
+    expect(useTaskLogMock).toHaveBeenLastCalledWith({
+      dagId: 'dummyDagId',
+      dagRunId: 'dummyDagRunId',
+      fullContent: false,
+      taskId: 'dummyTaskId',
+      taskTryNumber: 2,
+    });
+  });
+
+  test('Test Logs Full Content', () => {
+    const tryNumber = 2;
+    const { getByTestId } = render(
+      <Logs
+        dagId="dummyDagId"
+        dagRunId="dummyDagRunId"
+        taskId="dummyTaskId"
+        executionDate="2020:01:01T01:00+00:00"
+        tryNumber={tryNumber}
+      />,
+    );
+    expect(useTaskLogMock).toHaveBeenLastCalledWith({
+      dagId: 'dummyDagId',
+      dagRunId: 'dummyDagRunId',
+      fullContent: false,
+      taskId: 'dummyTaskId',
+      taskTryNumber: 1,
+    });
+    const fullContentCheckbox = getByTestId('full-content-checkbox');
+
+    fireEvent.click(fullContentCheckbox);
+
+    expect(useTaskLogMock).toHaveBeenLastCalledWith({
+      dagId: 'dummyDagId',
+      dagRunId: 'dummyDagRunId',
+      fullContent: true,
+      taskId: 'dummyTaskId',
+      taskTryNumber: 1,
+    });
+  });
+});
diff --git a/airflow/www/static/js/grid/details/content/taskInstance/Nav.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Nav.jsx
index 08c33d1e65..14e95c7ee7 100644
--- a/airflow/www/static/js/grid/details/content/taskInstance/Nav.jsx
+++ b/airflow/www/static/js/grid/details/content/taskInstance/Nav.jsx
@@ -19,13 +19,12 @@
 
 import React from 'react';
 import {
-  Button,
   Flex,
-  Link,
   Divider,
 } from '@chakra-ui/react';
 
 import { getMetaValue, appendSearchParams } from '../../../../utils';
+import LinkButton from '../../../components/LinkButton';
 
 const dagId = getMetaValue('dag_id');
 const isK8sExecutor = getMetaValue('k8s_or_k8scelery_executor') === 'True';
@@ -40,12 +39,6 @@ const taskUrl = getMetaValue('task_url');
 const gridUrl = getMetaValue('grid_url');
 const gridUrlNoRoot = getMetaValue('grid_url_no_root');
 
-const LinkButton = ({ children, ...rest }) => (
-  <Button as={Link} aria-label={children} variant="ghost" colorScheme="blue" 
{...rest}>
-    {children}
-  </Button>
-);
-
 const Nav = ({
   runId, taskId, executionDate, operator, isMapped,
 }) => {
@@ -105,7 +98,6 @@ const Nav = ({
       </Flex>
       <Divider mt={3} />
     </>
-
   );
 };
 
diff --git a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
index 90ef2e839e..7be2387ba5 100644
--- a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
+++ b/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+/* global localStorage */
+
 import React, { useState } from 'react';
 import {
   Box,
@@ -24,6 +26,11 @@ import {
   Divider,
   StackDivider,
   Text,
+  Tabs,
+  TabList,
+  Tab,
+  TabPanels,
+  TabPanel,
 } from '@chakra-ui/react';
 
 import RunAction from './taskActions/Run';
@@ -39,6 +46,8 @@ import { useGridData, useTasks } from '../../../api';
 import MappedInstances from './MappedInstances';
 import { getMetaValue } from '../../../../utils';
 
+const detailsPanelActiveTabIndex = 'detailsPanelActiveTabIndex';
+
 const dagId = getMetaValue('dag_id');
 
 const getTask = ({ taskId, runId, task }) => {
@@ -59,18 +68,43 @@ const TaskInstance = ({ taskId, runId }) => {
   const { data: { groups, dagRuns } } = useGridData();
   const { data: { tasks } } = useTasks(dagId);
 
+  const storageTabIndex = 
parseInt(localStorage.getItem(detailsPanelActiveTabIndex) || '0', 10);
+  const [preferedTabIndex, setPreferedTabIndex] = useState(storageTabIndex);
+
   const group = getTask({ taskId, runId, task: groups });
   const run = dagRuns.find((r) => r.runId === runId);
 
+  const handleTabsChange = (index) => {
+    localStorage.setItem(detailsPanelActiveTabIndex, index);
+    setPreferedTabIndex(index);
+  };
+
+  const { isMapped, extraLinks } = group;
+  const isGroup = !!group.children;
+
+  const isSimpleTask = !isMapped && !isGroup;
+
+  let isPreferedTabDisplayed = false;
+
+  switch (preferedTabIndex) {
+    case 0:
+      isPreferedTabDisplayed = true;
+      break;
+    case 1:
+      isPreferedTabDisplayed = isSimpleTask;
+      break;
+    default:
+      isPreferedTabDisplayed = false;
+  }
+
+  const selectedTabIndex = isPreferedTabDisplayed ? preferedTabIndex : 0;
+
   if (!group || !run) return null;
 
   const { executionDate } = run;
   const task = tasks.find((t) => t.taskId === taskId);
   const operator = task && task.classRef && task.classRef.className ? 
task.classRef.className : '';
 
-  const isGroup = !!group.children;
-  const { isMapped, extraLinks } = group;
-
   const instance = group.instances.find((ti) => ti.runId === runId);
 
   let taskActionsTitle = 'Task Actions';
@@ -89,63 +123,91 @@ const TaskInstance = ({ taskId, runId }) => {
           operator={operator}
         />
       )}
-      {!isGroup && (
-        <Box my={3}>
-          <Text as="strong">{taskActionsTitle}</Text>
-          <Divider my={2} />
-          <VStack justifyContent="center" divider={<StackDivider my={3} />}>
-            <RunAction
-              runId={runId}
-              taskId={taskId}
+      <Tabs size="lg" index={selectedTabIndex} onChange={handleTabsChange}>
+        <TabList>
+          <Tab>
+            <Text as="strong">Details</Text>
+          </Tab>
+
+          { isSimpleTask && (
+            <Tab>
+              <Text as="strong">Logs</Text>
+            </Tab>
+          )}
+        </TabList>
+
+        <TabPanels>
+
+          {/* Details Tab */}
+          <TabPanel>
+            <Box py="4px">
+              {!isGroup && (
+                <Box my={3}>
+                  <Text as="strong">{taskActionsTitle}</Text>
+                  <Divider my={2} />
+                  <VStack justifyContent="center" divider={<StackDivider 
my={3} />}>
+                    <RunAction
+                      runId={runId}
+                      taskId={taskId}
+                      dagId={dagId}
+                      mapIndexes={selectedRows}
+                    />
+                    <ClearAction
+                      runId={runId}
+                      taskId={taskId}
+                      dagId={dagId}
+                      executionDate={executionDate}
+                      mapIndexes={selectedRows}
+                    />
+                    <MarkFailedAction
+                      runId={runId}
+                      taskId={taskId}
+                      dagId={dagId}
+                      mapIndexes={selectedRows}
+                    />
+                    <MarkSuccessAction
+                      runId={runId}
+                      taskId={taskId}
+                      dagId={dagId}
+                      mapIndexes={selectedRows}
+                    />
+                  </VStack>
+                  <Divider my={2} />
+                </Box>
+              )}
+              <Details instance={instance} group={group} operator={operator} />
+              <ExtraLinks
+                taskId={taskId}
+                dagId={dagId}
+                executionDate={executionDate}
+                extraLinks={extraLinks}
+              />
+              {isMapped && (
+                <MappedInstances
+                  dagId={dagId}
+                  runId={runId}
+                  taskId={taskId}
+                  selectRows={setSelectedRows}
+                />
+              )}
+            </Box>
+          </TabPanel>
+
+          {/* Logs Tab */}
+          { isSimpleTask && (
+          <TabPanel>
+            <Logs
               dagId={dagId}
-              mapIndexes={selectedRows}
-            />
-            <ClearAction
-              runId={runId}
+              dagRunId={runId}
               taskId={taskId}
-              dagId={dagId}
               executionDate={executionDate}
-              mapIndexes={selectedRows}
-            />
-            <MarkFailedAction
-              runId={runId}
-              taskId={taskId}
-              dagId={dagId}
-              mapIndexes={selectedRows}
+              tryNumber={instance.tryNumber}
             />
-            <MarkSuccessAction
-              runId={runId}
-              taskId={taskId}
-              dagId={dagId}
-              mapIndexes={selectedRows}
-            />
-          </VStack>
-          <Divider my={2} />
-        </Box>
-      )}
-      {!isMapped && (
-        <Logs
-          dagId={dagId}
-          taskId={taskId}
-          executionDate={executionDate}
-          tryNumber={instance.tryNumber}
-        />
-      )}
-      <Details instance={instance} group={group} operator={operator} />
-      <ExtraLinks
-        taskId={taskId}
-        dagId={dagId}
-        executionDate={executionDate}
-        extraLinks={extraLinks}
-      />
-      {isMapped && (
-        <MappedInstances
-          dagId={dagId}
-          runId={runId}
-          taskId={taskId}
-          selectRows={setSelectedRows}
-        />
-      )}
+
+          </TabPanel>
+          )}
+        </TabPanels>
+      </Tabs>
     </Box>
   );
 };
diff --git a/airflow/www/templates/airflow/dag.html 
b/airflow/www/templates/airflow/dag.html
index c43af805c4..82a58a69dd 100644
--- a/airflow/www/templates/airflow/dag.html
+++ b/airflow/www/templates/airflow/dag.html
@@ -68,6 +68,7 @@
   <meta name="task_instances_list_url" content="{{ 
url_for('TaskInstanceModelView.list') }}">
   <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_') }}">
+  <meta name="task_log_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_log_endpoint_get_log', 
dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_', task_id='_TASK_ID_', 
task_try_number='-1') }}">
   <!-- End Urls -->
   <meta name="is_paused" content="{{ dag_is_paused }}">
   <meta name="csrf_token" content="{{ csrf_token() }}">

Reply via email to