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() }}">