You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by bb...@apache.org on 2022/08/14 22:37:21 UTC
[airflow] branch main updated: Grid logs for mapped instances (#25610)
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 88292837ba Grid logs for mapped instances (#25610)
88292837ba is described below
commit 88292837ba219c99f94bf3e03c5befda470c83b4
Author: pierrejeambrun <pi...@gmail.com>
AuthorDate: Mon Aug 15 00:36:53 2022 +0200
Grid logs for mapped instances (#25610)
* Add logs for mapped tasks
* Mapped Task for download link, see more and paginated Table.
* Handle refresh on select mapped task instance + other fix
* Add useSelection tests for mapIndex
* Stop propagation on mapped instance icon clicked
* Add nav link for mapped task instances
* Add Back to Dynamic Task Summary button
* Move MappedInstances to its own tab
* Update following code review
* Simplifies the logic by using get_task_instance endpoint
* Pull instance either from useGridData or useTaskInstance
* Update test
---
airflow/www/static/js/api/index.ts | 22 +--
.../js/api/{useTaskLog.tsx => useTaskInstance.tsx} | 40 ++++--
airflow/www/static/js/api/useTaskLog.tsx | 7 +-
.../www/static/js/dag/details/BreadcrumbText.tsx | 11 +-
airflow/www/static/js/dag/details/Header.tsx | 14 +-
airflow/www/static/js/dag/details/index.tsx | 4 +-
.../BackToTaskSummary.tsx} | 33 +++--
.../static/js/dag/details/taskInstance/Details.tsx | 7 +
.../dag/details/taskInstance/Logs/LogLink.test.tsx | 4 +-
.../js/dag/details/taskInstance/Logs/LogLink.tsx | 4 +-
.../dag/details/taskInstance/Logs/index.test.tsx | 37 +++++
.../js/dag/details/taskInstance/Logs/index.tsx | 16 ++-
.../dag/details/taskInstance/MappedInstances.tsx | 84 +++---------
.../www/static/js/dag/details/taskInstance/Nav.tsx | 8 +-
.../static/js/dag/details/taskInstance/index.tsx | 152 +++++++++++----------
.../{ActionButton.jsx => ActionButton.tsx} | 9 +-
.../dag/details/taskInstance/taskActions/index.tsx | 76 +++++++++++
.../taskActions/types.tsx} | 24 +---
airflow/www/static/js/dag/useSelection.test.tsx | 12 +-
airflow/www/static/js/dag/useSelection.ts | 11 +-
airflow/www/static/js/types/index.ts | 1 +
airflow/www/templates/airflow/dag.html | 1 +
22 files changed, 363 insertions(+), 214 deletions(-)
diff --git a/airflow/www/static/js/api/index.ts b/airflow/www/static/js/api/index.ts
index 5bc3a1d4af..35b2eacbd2 100644
--- a/airflow/www/static/js/api/index.ts
+++ b/airflow/www/static/js/api/index.ts
@@ -36,6 +36,7 @@ import useDatasets from './useDatasets';
import useDataset from './useDataset';
import useDatasetEvents from './useDatasetEvents';
import useUpstreamDatasetEvents from './useUpstreamDatasetEvents';
+import useTaskInstance from './useTaskInstance';
axios.interceptors.response.use(
(res: AxiosResponse) => (res.data ? camelcaseKeys(res.data, { deep: true }) : res),
@@ -45,19 +46,20 @@ axios.defaults.headers.common.Accept = 'application/json';
export {
useClearRun,
- useQueueRun,
- useMarkFailedRun,
- useMarkSuccessRun,
- useRunTask,
useClearTask,
- useMarkFailedTask,
- useMarkSuccessTask,
- useExtraLinks,
useConfirmMarkTask,
- useGridData,
- useMappedInstances,
- useDatasets,
useDataset,
useDatasetEvents,
+ useDatasets,
+ useExtraLinks,
+ useGridData,
+ useMappedInstances,
+ useMarkFailedRun,
+ useMarkFailedTask,
+ useMarkSuccessRun,
+ useMarkSuccessTask,
+ useQueueRun,
+ useRunTask,
+ useTaskInstance,
useUpstreamDatasetEvents,
};
diff --git a/airflow/www/static/js/api/useTaskLog.tsx b/airflow/www/static/js/api/useTaskInstance.tsx
similarity index 54%
copy from airflow/www/static/js/api/useTaskLog.tsx
copy to airflow/www/static/js/api/useTaskInstance.tsx
index 43e7f957c2..1678bb85e0 100644
--- a/airflow/www/static/js/api/useTaskLog.tsx
+++ b/airflow/www/static/js/api/useTaskInstance.tsx
@@ -18,37 +18,53 @@
*/
import axios, { AxiosResponse } from 'axios';
+import type { API, TaskInstance } from 'src/types';
import { useQuery } from 'react-query';
import { useAutoRefresh } from 'src/context/autorefresh';
import { getMetaValue } from 'src/utils';
-const taskLogApi = getMetaValue('task_log_api');
+/* GridData.TaskInstance and API.TaskInstance are not compatible at the moment.
+ * Remove this function when changing the api response for grid_data_url to comply
+ * with API.TaskInstance.
+ */
+const convertTaskInstance = (
+ ti:
+ API.TaskInstance,
+) => ({ ...ti, runId: ti.dagRunId }) as TaskInstance;
+
+const taskInstanceApi = getMetaValue('task_instance_api');
-const useTaskLog = ({
- dagId, dagRunId, taskId, taskTryNumber, fullContent,
+const useTaskInstance = ({
+ dagId, dagRunId, taskId, mapIndex, enabled,
}: {
dagId: string,
dagRunId: string,
- taskId: string,
- taskTryNumber: number,
- fullContent: boolean,
+ taskId: string | null,
+ mapIndex?: number,
+ enabled: boolean
}) => {
let url: string = '';
- if (taskLogApi) {
- url = taskLogApi.replace('_DAG_RUN_ID_', dagRunId).replace('_TASK_ID_', taskId).replace(/-1$/, taskTryNumber.toString());
+ if (taskInstanceApi) {
+ url = taskInstanceApi.replace('_DAG_RUN_ID_', dagRunId).replace('_TASK_ID_', taskId || '');
+ }
+
+ if (mapIndex !== undefined && mapIndex >= 0) {
+ url += `/${mapIndex.toString()}`;
}
const { isRefreshOn } = useAutoRefresh();
return useQuery(
- ['taskLogs', dagId, dagRunId, taskId, taskTryNumber, fullContent],
- () => axios.get<AxiosResponse, string>(url, { headers: { Accept: 'text/plain' }, params: { full_content: fullContent } }),
+ ['taskIntance', dagId, dagRunId, taskId, mapIndex],
+ () => axios.get<AxiosResponse, API.TaskInstance>(url, { headers: { Accept: 'text/plain' } }),
{
- placeholderData: '',
+ placeholderData: {},
refetchInterval: isRefreshOn && (autoRefreshInterval || 1) * 1000,
+ enabled,
+ select: convertTaskInstance,
},
);
};
-export default useTaskLog;
+export default useTaskInstance;
diff --git a/airflow/www/static/js/api/useTaskLog.tsx b/airflow/www/static/js/api/useTaskLog.tsx
index 43e7f957c2..f389f4c118 100644
--- a/airflow/www/static/js/api/useTaskLog.tsx
+++ b/airflow/www/static/js/api/useTaskLog.tsx
@@ -26,12 +26,13 @@ import { getMetaValue } from 'src/utils';
const taskLogApi = getMetaValue('task_log_api');
const useTaskLog = ({
- dagId, dagRunId, taskId, taskTryNumber, fullContent,
+ dagId, dagRunId, taskId, taskTryNumber, mapIndex, fullContent,
}: {
dagId: string,
dagRunId: string,
taskId: string,
taskTryNumber: number,
+ mapIndex?: number,
fullContent: boolean,
}) => {
let url: string = '';
@@ -42,8 +43,8 @@ const useTaskLog = ({
const { isRefreshOn } = useAutoRefresh();
return useQuery(
- ['taskLogs', dagId, dagRunId, taskId, taskTryNumber, fullContent],
- () => axios.get<AxiosResponse, string>(url, { headers: { Accept: 'text/plain' }, params: { full_content: fullContent } }),
+ ['taskLogs', dagId, dagRunId, taskId, mapIndex, taskTryNumber, fullContent],
+ () => axios.get<AxiosResponse, string>(url, { headers: { Accept: 'text/plain' }, params: { map_index: mapIndex, full_content: fullContent } }),
{
placeholderData: '',
refetchInterval: isRefreshOn && (autoRefreshInterval || 1) * 1000,
diff --git a/airflow/www/static/js/dag/details/BreadcrumbText.tsx b/airflow/www/static/js/dag/details/BreadcrumbText.tsx
index 85d5f6dec5..b0cf19161a 100644
--- a/airflow/www/static/js/dag/details/BreadcrumbText.tsx
+++ b/airflow/www/static/js/dag/details/BreadcrumbText.tsx
@@ -30,7 +30,16 @@ interface Props {
const BreadcrumbText = ({ label, value }: Props) => (
<Box position="relative">
- <Heading as="h5" size="sm" color="gray.300" position="absolute" top="-12px">{label}</Heading>
+ <Heading
+ as="h5"
+ size="sm"
+ color="gray.300"
+ position="absolute"
+ top="-12px"
+ whiteSpace="nowrap"
+ >
+ {label}
+ </Heading>
<Heading as="h3" size="md">{value}</Heading>
</Box>
);
diff --git a/airflow/www/static/js/dag/details/Header.tsx b/airflow/www/static/js/dag/details/Header.tsx
index 502dba5942..3b957f6a17 100644
--- a/airflow/www/static/js/dag/details/Header.tsx
+++ b/airflow/www/static/js/dag/details/Header.tsx
@@ -38,7 +38,7 @@ const dagId = getMetaValue('dag_id');
const Header = () => {
const { data: { dagRuns, groups } } = useGridData();
- const { selected: { taskId, runId }, onSelect, clearSelection } = useSelection();
+ const { selected: { taskId, runId, mapIndex }, onSelect, clearSelection } = useSelection();
const dagRun = dagRuns.find((r) => r.runId === runId);
// clearSelection if the current selected dagRun is
@@ -75,7 +75,8 @@ const Header = () => {
const isDagDetails = !runId && !taskId;
const isRunDetails = !!(runId && !taskId);
- const isTaskDetails = runId && taskId;
+ const isTaskDetails = runId && taskId && mapIndex === null;
+ const isMappedTaskDetails = runId && taskId && mapIndex !== null;
return (
<Breadcrumb separator={<Text color="gray.300">/</Text>}>
@@ -93,11 +94,18 @@ const Header = () => {
)}
{taskId && (
<BreadcrumbItem isCurrentPage mt={4}>
- <BreadcrumbLink _hover={isTaskDetails ? { cursor: 'default' } : undefined}>
+ <BreadcrumbLink onClick={() => onSelect({ runId, taskId })} _hover={isTaskDetails ? { cursor: 'default' } : undefined}>
<BreadcrumbText label="Task" value={`${taskName}${group?.isMapped ? ' []' : ''}`} />
</BreadcrumbLink>
</BreadcrumbItem>
)}
+ {mapIndex !== null && (
+ <BreadcrumbItem isCurrentPage mt={4}>
+ <BreadcrumbLink _hover={isMappedTaskDetails ? { cursor: 'default' } : undefined}>
+ <BreadcrumbText label="Map Index" value={mapIndex} />
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ )}
</Breadcrumb>
);
};
diff --git a/airflow/www/static/js/dag/details/index.tsx b/airflow/www/static/js/dag/details/index.tsx
index 9ba19cabf2..557bc1b8b9 100644
--- a/airflow/www/static/js/dag/details/index.tsx
+++ b/airflow/www/static/js/dag/details/index.tsx
@@ -32,7 +32,7 @@ import DagRunContent from './dagRun';
import DagContent from './Dag';
const Details = () => {
- const { selected: { runId, taskId } } = useSelection();
+ const { selected: { runId, taskId, mapIndex }, onSelect } = useSelection();
return (
<Flex flexDirection="column" pl={3} mr={3} flexGrow={1} maxWidth="750px">
<Header />
@@ -46,6 +46,8 @@ const Details = () => {
<TaskInstanceContent
runId={runId}
taskId={taskId}
+ mapIndex={mapIndex === null ? undefined : mapIndex}
+ onSelect={onSelect}
/>
)}
</Box>
diff --git a/airflow/www/static/js/dag/details/BreadcrumbText.tsx b/airflow/www/static/js/dag/details/taskInstance/BackToTaskSummary.tsx
similarity index 64%
copy from airflow/www/static/js/dag/details/BreadcrumbText.tsx
copy to airflow/www/static/js/dag/details/taskInstance/BackToTaskSummary.tsx
index 85d5f6dec5..26918d5cb5 100644
--- a/airflow/www/static/js/dag/details/BreadcrumbText.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/BackToTaskSummary.tsx
@@ -18,21 +18,28 @@
*/
import React from 'react';
-import {
- Box,
- Heading,
-} from '@chakra-ui/react';
+import { Button, Flex } from '@chakra-ui/react';
interface Props {
- label: string;
- value: React.ReactNode;
+ isMapIndexDefined: boolean;
+ onClick: () => void;
}
-const BreadcrumbText = ({ label, value }: Props) => (
- <Box position="relative">
- <Heading as="h5" size="sm" color="gray.300" position="absolute" top="-12px">{label}</Heading>
- <Heading as="h3" size="md">{value}</Heading>
- </Box>
-);
+const BackToTaskSummary = ({ isMapIndexDefined, onClick }: Props) => {
+ if (!isMapIndexDefined) return null;
-export default BreadcrumbText;
+ return (
+ <Flex justifyContent="right">
+ <Button
+ variant="ghost"
+ colorScheme="blue"
+ onClick={onClick}
+ size="lg"
+ >
+ Back to Dynamic Task Summary
+ </Button>
+ </Flex>
+ );
+};
+
+export default BackToTaskSummary;
diff --git a/airflow/www/static/js/dag/details/taskInstance/Details.tsx b/airflow/www/static/js/dag/details/taskInstance/Details.tsx
index 91d453b3c4..ed0174b72b 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Details.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Details.tsx
@@ -52,6 +52,7 @@ const Details = ({ instance, group }: Props) => {
endDate,
state,
mappedStates,
+ mapIndex,
} = instance;
const {
@@ -142,6 +143,12 @@ const Details = ({ instance, group }: Props) => {
<Td>Run ID</Td>
<Td><Text whiteSpace="nowrap"><ClipboardText value={runId} /></Text></Td>
</Tr>
+ {mapIndex !== undefined && (
+ <Tr>
+ <Td>Map Index</Td>
+ <Td>{mapIndex}</Td>
+ </Tr>
+ )}
{operator && (
<Tr>
<Td>Operator</Td>
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.test.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.test.tsx
index c6ae0ce49d..e3e235596e 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.test.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.test.tsx
@@ -41,7 +41,7 @@ describe('Test LogLink Component.', () => {
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}`,
+ `?dag_id=dummyDagId&task_id=dummyTaskId&execution_date=2020%3A01%3A01T01%3A00%2B00%3A00&map_index=-1&format=file&try_number=${tryNumber}`,
)).toBeTruthy();
});
@@ -61,7 +61,7 @@ describe('Test LogLink Component.', () => {
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}`,
+ `?dag_id=dummyDagId&task_id=dummyTaskId&execution_date=2020%3A01%3A01T01%3A00%2B00%3A00&map_index=-1&try_number=${tryNumber}`,
)).toBeTruthy();
});
});
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.tsx
index 49683112b5..9ae2704ce6 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/LogLink.tsx
@@ -32,15 +32,17 @@ interface Props {
executionDate: DagRun['executionDate'];
isInternal?: boolean;
tryNumber: TaskInstance['tryNumber'];
+ mapIndex?: TaskInstance['mapIndex'];
}
const LogLink = ({
- dagId, taskId, executionDate, isInternal, tryNumber,
+ dagId, taskId, executionDate, isInternal, tryNumber, mapIndex,
}: Props) => {
let fullMetadataUrl = `${isInternal ? logsWithMetadataUrl : externalLogUrl
}?dag_id=${encodeURIComponent(dagId)
}&task_id=${encodeURIComponent(taskId)
}&execution_date=${encodeURIComponent(executionDate)
+ }&map_index=${encodeURIComponent(mapIndex?.toString() ?? '-1')
}`;
if (isInternal && tryNumber) {
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/index.test.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/index.test.tsx
index 1f748a1df7..6c11c59305 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/index.test.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/index.test.tsx
@@ -78,6 +78,43 @@ describe('Test Logs Component.', () => {
{ exact: false },
)).toBeDefined();
expect(getByText('AIRFLOW_CTX_DAG_ID=test_ui_grid', { exact: false })).toBeDefined();
+
+ expect(useTaskLogMock).toHaveBeenLastCalledWith({
+ dagId: 'dummyDagId',
+ dagRunId: 'dummyDagRunId',
+ fullContent: false,
+ taskId: 'dummyTaskId',
+ taskTryNumber: 1,
+ });
+ });
+
+ test('Test Logs Content Mapped Task', () => {
+ const tryNumber = 2;
+ const { getByText } = render(
+ <Logs
+ dagId="dummyDagId"
+ dagRunId="dummyDagRunId"
+ taskId="dummyTaskId"
+ executionDate="2020:01:01T01:00+00:00"
+ mapIndex={1}
+ tryNumber={tryNumber}
+ />,
+ );
+ expect(getByText('[2022-06-04, 00:00:01 UTC] {taskinstance.py:1329} INFO -', { exact: false })).toBeDefined();
+ expect(getByText(
+ '[2022-06-04, 00:00:01 UTC] {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();
+
+ expect(useTaskLogMock).toHaveBeenLastCalledWith({
+ dagId: 'dummyDagId',
+ dagRunId: 'dummyDagRunId',
+ fullContent: false,
+ mapIndex: 1,
+ taskId: 'dummyTaskId',
+ taskTryNumber: 1,
+ });
});
test('Test Logs Attempt Select Button', () => {
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx
index 58bb030ed8..9a2b33205a 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx
@@ -84,6 +84,7 @@ interface Props {
dagId: Dag['id'];
dagRunId: DagRun['runId'];
taskId: TaskInstance['taskId'];
+ mapIndex?: TaskInstance['mapIndex'];
executionDate: DagRun['executionDate'];
tryNumber: TaskInstance['tryNumber'];
}
@@ -92,6 +93,7 @@ const Logs = ({
dagId,
dagRunId,
taskId,
+ mapIndex,
executionDate,
tryNumber,
}: Props) => {
@@ -106,6 +108,7 @@ const Logs = ({
dagId,
dagRunId,
taskId,
+ mapIndex,
taskTryNumber: selectedAttempt,
fullContent: shouldRequestFullContent,
});
@@ -113,7 +116,11 @@ const Logs = ({
const params = new URLSearchParams({
task_id: taskId,
execution_date: executionDate,
- }).toString();
+ });
+
+ if (mapIndex !== undefined) {
+ params.append('map_index', mapIndex.toString());
+ }
const { parsedLogs, fileSources = [] } = useMemo(
() => parseLogs(
@@ -136,7 +143,7 @@ const Logs = ({
useEffect(() => {
// Reset fileSourceFilters and selected attempt when changing to
// a task that do not have those filters anymore.
- if (!internalIndexes.includes(selectedAttempt)) {
+ if (!internalIndexes.includes(selectedAttempt) && internalIndexes.length) {
setSelectedAttempt(internalIndexes[0]);
}
@@ -151,7 +158,7 @@ const Logs = ({
return (
<>
- {tryNumber! > 0 && (
+ {tryNumber !== undefined && (
<>
<Text as="span"> (by attempts)</Text>
<Flex my={1} justifyContent="space-between">
@@ -227,9 +234,10 @@ const Logs = ({
executionDate={executionDate}
isInternal
tryNumber={tryNumber}
+ mapIndex={mapIndex}
/>
<LinkButton
- href={`${logUrl}&${params}`}
+ href={`${logUrl}&${params.toString()}`}
>
See More
</LinkButton>
diff --git a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
index 8b45a4be2c..9796814fcc 100644
--- a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
@@ -17,51 +17,32 @@
* under the License.
*/
-import React, { useState, useMemo } from 'react';
+import React, {
+ useState, useMemo,
+} from 'react';
import {
Flex,
Text,
Box,
- Link,
- IconButton,
- IconButtonProps,
} from '@chakra-ui/react';
import { snakeCase } from 'lodash';
-import {
- MdDetails, MdCode, MdSyncAlt, MdReorder,
-} from 'react-icons/md';
-import type { SortingRule } from 'react-table';
+import type { Row, SortingRule } from 'react-table';
-import { getMetaValue } from 'src/utils';
import { formatDuration, getDuration } from 'src/datetime_utils';
import { useMappedInstances } from 'src/api';
import { SimpleStatus } from 'src/dag/StatusBox';
import { Table } from 'src/components/Table';
import Time from 'src/components/Time';
-const canEdit = getMetaValue('can_edit') === 'True';
-const renderedTemplatesUrl = getMetaValue('rendered_templates_url');
-const logUrl = getMetaValue('log_url');
-const taskUrl = getMetaValue('task_url');
-const xcomUrl = getMetaValue('xcom_url');
-
-interface IconLinkProps extends IconButtonProps {
- href: string;
-}
-
-const IconLink = (props: IconLinkProps) => (
- <IconButton as={Link} variant="ghost" colorScheme="blue" fontSize="3xl" {...props} />
-);
-
interface Props {
dagId: string;
runId: string;
taskId: string;
- selectRows: (selectedRows: number[]) => void;
+ onRowClicked: (row: Row) => void;
}
const MappedInstances = ({
- dagId, runId, taskId, selectRows,
+ dagId, runId, taskId, onRowClicked,
}: Props) => {
const limit = 25;
const [offset, setOffset] = useState(0);
@@ -78,41 +59,18 @@ const MappedInstances = ({
dagId, runId, taskId, limit, offset, order,
});
- const data = useMemo(
- () => taskInstances.map((mi) => {
- const params = new URLSearchParams({
- dag_id: dagId.toString(),
- task_id: mi.taskId || '',
- execution_date: mi.executionDate || '',
- map_index: (mi.mapIndex || -1).toString(),
- }).toString();
- const detailsLink = `${taskUrl}&${params}`;
- const renderedLink = `${renderedTemplatesUrl}&${params}`;
- const logLink = `${logUrl}&${params}`;
- const xcomLink = `${xcomUrl}&${params}`;
- return {
- ...mi,
- state: (
- <Flex alignItems="center">
- <SimpleStatus state={mi.state === undefined || mi.state === 'none' ? null : mi.state} mx={2} />
- {mi.state || 'no status'}
- </Flex>
- ),
- duration: mi.duration && formatDuration(getDuration(mi.startDate, mi.endDate)),
- startDate: <Time dateTime={mi.startDate} />,
- endDate: <Time dateTime={mi.endDate} />,
- links: (
- <Flex alignItems="center">
- <IconLink mr={1} title="Details" aria-label="Details" icon={<MdDetails />} href={detailsLink} />
- <IconLink mr={1} title="Rendered Templates" aria-label="Rendered Templates" icon={<MdCode />} href={renderedLink} />
- <IconLink mr={1} title="Log" aria-label="Log" icon={<MdReorder />} href={logLink} />
- <IconLink title="XCom" fontWeight="bold" aria-label="XCom" icon={<MdSyncAlt />} href={xcomLink} />
- </Flex>
- ),
- };
- }),
- [dagId, taskInstances],
- );
+ const data = useMemo(() => taskInstances.map((mi) => ({
+ ...mi,
+ state: (
+ <Flex alignItems="center">
+ <SimpleStatus state={mi.state === undefined || mi.state === 'none' ? null : mi.state} mx={2} />
+ {mi.state || 'no status'}
+ </Flex>
+ ),
+ duration: mi.duration && formatDuration(getDuration(mi.startDate, mi.endDate)),
+ startDate: <Time dateTime={mi.startDate} />,
+ endDate: <Time dateTime={mi.endDate} />,
+ })), [taskInstances]);
const columns = useMemo(
() => [
@@ -139,10 +97,6 @@ const MappedInstances = ({
accessor: 'endDate',
disableSortBy: true,
},
- {
- disableSortBy: true,
- accessor: 'links',
- },
],
[],
);
@@ -165,7 +119,7 @@ const MappedInstances = ({
sortBy,
}}
isLoading={isLoading}
- selectRows={canEdit ? selectRows : undefined}
+ onRowClicked={onRowClicked}
/>
</Box>
);
diff --git a/airflow/www/static/js/dag/details/taskInstance/Nav.tsx b/airflow/www/static/js/dag/details/taskInstance/Nav.tsx
index 36782f067e..6a2c314930 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Nav.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Nav.tsx
@@ -46,16 +46,18 @@ interface Props {
executionDate: string;
operator?: string;
isMapped?: boolean;
+ mapIndex?: number;
}
const Nav = ({
- runId, taskId, executionDate, operator, isMapped = false,
+ runId, taskId, executionDate, operator, isMapped = false, mapIndex,
}: Props) => {
if (!taskId) return null;
const params = new URLSearchParams({
task_id: taskId,
execution_date: executionDate,
- }).toString();
+ map_index: mapIndex?.toString() ?? '-1',
+ });
const detailsLink = `${taskUrl}&${params}`;
const renderedLink = `${renderedTemplatesUrl}&${params}`;
const logLink = `${logUrl}&${params}`;
@@ -90,7 +92,7 @@ const Nav = ({
return (
<>
<Flex flexWrap="wrap">
- {!isMapped && (
+ {(!isMapped || mapIndex !== undefined) && (
<>
<LinkButton href={detailsLink}>Task Instance Details</LinkButton>
<LinkButton href={renderedLink}>Rendered Template</LinkButton>
diff --git a/airflow/www/static/js/dag/details/taskInstance/index.tsx b/airflow/www/static/js/dag/details/taskInstance/index.tsx
index e0490fd2e6..40248e2428 100644
--- a/airflow/www/static/js/dag/details/taskInstance/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/index.tsx
@@ -22,9 +22,6 @@
import React, { useState } from 'react';
import {
Box,
- VStack,
- Divider,
- StackDivider,
Text,
Tabs,
TabList,
@@ -33,19 +30,20 @@ import {
TabPanel,
} from '@chakra-ui/react';
-import { useGridData } from 'src/api';
+import { useGridData, useTaskInstance } from 'src/api';
import { getMetaValue, getTask } from 'src/utils';
-import type { Task, DagRun } from 'src/types';
+import type {
+ Task, DagRun, TaskInstance as TaskInstanceType,
+} from 'src/types';
-import RunAction from './taskActions/Run';
-import ClearAction from './taskActions/Clear';
-import MarkFailedAction from './taskActions/MarkFailed';
-import MarkSuccessAction from './taskActions/MarkSuccess';
+import type { SelectionProps } from 'src/dag/useSelection';
import ExtraLinks from './ExtraLinks';
import Logs from './Logs';
import TaskNav from './Nav';
import Details from './Details';
import MappedInstances from './MappedInstances';
+import TaskActions from './taskActions';
+import BackToTaskSummary from './BackToTaskSummary';
const detailsPanelActiveTabIndex = 'detailsPanelActiveTabIndex';
@@ -54,10 +52,15 @@ const dagId = getMetaValue('dag_id')!;
interface Props {
taskId: Task['id'];
runId: DagRun['runId'];
+ mapIndex: TaskInstanceType['mapIndex'];
+ onSelect: (selectionProps: SelectionProps) => void;
}
-const TaskInstance = ({ taskId, runId }: Props) => {
- const [selectedRows, setSelectedRows] = useState<number[]>([]);
+const TaskInstance = ({
+ taskId, runId, mapIndex, onSelect,
+}: Props) => {
+ const isMapIndexDefined = !(mapIndex === undefined);
+ const actionsMapIndexes = isMapIndexDefined ? [mapIndex] : [];
const { data: { dagRuns, groups } } = useGridData();
const storageTabIndex = parseInt(localStorage.getItem(detailsPanelActiveTabIndex) || '0', 10);
@@ -66,17 +69,28 @@ const TaskInstance = ({ taskId, runId }: Props) => {
const group = getTask({ taskId, task: groups });
const run = dagRuns.find((r) => r.runId === runId);
- if (!group || !run) return null;
+ const children = group?.children;
+ const isMapped = group?.isMapped;
+ const operator = group?.operator;
- const { children, isMapped, operator } = group;
+ const isMappedTaskSummary = !!isMapped && !isMapIndexDefined && taskId;
+ const isGroup = !!children;
+ const isGroupOrMappedTaskSummary = (isGroup || isMappedTaskSummary);
+
+ const { data: mappedTaskInstance } = useTaskInstance({
+ dagId, dagRunId: runId, taskId, mapIndex, enabled: isMapIndexDefined,
+ });
+
+ const instance = isMapIndexDefined
+ ? mappedTaskInstance
+ : group?.instances.find((ti) => ti.runId === runId);
const handleTabsChange = (index: number) => {
localStorage.setItem(detailsPanelActiveTabIndex, index.toString());
setPreferedTabIndex(index);
};
- const isGroup = !!children;
- const isSimpleTask = !isMapped && !isGroup;
+ if (!group || !run || !instance) return null;
let isPreferedTabDisplayed = false;
@@ -85,7 +99,7 @@ const TaskInstance = ({ taskId, runId }: Props) => {
isPreferedTabDisplayed = true;
break;
case 1:
- isPreferedTabDisplayed = isSimpleTask;
+ isPreferedTabDisplayed = !isGroup;
break;
default:
isPreferedTabDisplayed = false;
@@ -95,12 +109,9 @@ const TaskInstance = ({ taskId, runId }: Props) => {
const { executionDate } = run;
- const instance = group.instances.find((ti) => ti.runId === runId);
- if (!instance) return null;
-
let taskActionsTitle = 'Task Actions';
if (isMapped) {
- taskActionsTitle += ` for ${selectedRows.length || 'all'} mapped task${selectedRows.length !== 1 ? 's' : ''}`;
+ taskActionsTitle += ` for ${actionsMapIndexes.length || 'all'} mapped task${actionsMapIndexes.length !== 1 ? 's' : ''}`;
}
return (
@@ -110,6 +121,7 @@ const TaskInstance = ({ taskId, runId }: Props) => {
taskId={taskId}
runId={runId}
isMapped={isMapped}
+ mapIndex={mapIndex}
executionDate={executionDate}
operator={operator}
/>
@@ -119,49 +131,37 @@ const TaskInstance = ({ taskId, runId }: Props) => {
<Tab>
<Text as="strong">Details</Text>
</Tab>
- { isSimpleTask && (
+ {isMappedTaskSummary && (
+ <Tab>
+ <Text as="strong">Mapped Tasks</Text>
+ </Tab>
+ )}
+ {!isGroupOrMappedTaskSummary && (
<Tab>
<Text as="strong">Logs</Text>
</Tab>
)}
</TabList>
+
+ <BackToTaskSummary
+ isMapIndexDefined={isMapIndexDefined}
+ onClick={() => onSelect({ runId, taskId })}
+ />
+
<TabPanels>
+
{/* Details Tab */}
- <TabPanel>
+ <TabPanel pt={isMapIndexDefined ? '0px' : undefined}>
<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>
+ <TaskActions
+ title={taskActionsTitle}
+ runId={runId}
+ taskId={taskId}
+ dagId={dagId}
+ executionDate={executionDate}
+ mapIndexes={actionsMapIndexes}
+ />
)}
<Details instance={instance} group={group} />
{!isMapped && (
@@ -172,28 +172,36 @@ const TaskInstance = ({ taskId, runId }: Props) => {
extraLinks={group?.extraLinks || []}
/>
)}
- {isMapped && taskId && (
- <MappedInstances
- dagId={dagId}
- runId={runId}
- taskId={taskId}
- selectRows={setSelectedRows}
- />
- )}
</Box>
</TabPanel>
+
{/* Logs Tab */}
- { isSimpleTask && (
- <TabPanel>
- <Logs
- dagId={dagId}
- dagRunId={runId}
- taskId={taskId!}
- executionDate={executionDate}
- tryNumber={instance?.tryNumber}
- />
- </TabPanel>
+ {!isGroupOrMappedTaskSummary && (
+ <TabPanel pt={isMapIndexDefined ? '0px' : undefined}>
+ <Logs
+ dagId={dagId}
+ dagRunId={runId}
+ taskId={taskId!}
+ mapIndex={mapIndex}
+ executionDate={executionDate}
+ tryNumber={instance?.tryNumber}
+ />
+ </TabPanel>
)}
+
+ {/* Mapped Task Instances Tab */}
+ {
+ isMappedTaskSummary && (
+ <TabPanel>
+ <MappedInstances
+ dagId={dagId}
+ runId={runId}
+ taskId={taskId}
+ onRowClicked={(row) => onSelect({ runId, taskId, mapIndex: row.values.mapIndex })}
+ />
+ </TabPanel>
+ )
+ }
</TabPanels>
</Tabs>
</Box>
diff --git a/airflow/www/static/js/dag/details/taskInstance/taskActions/ActionButton.jsx b/airflow/www/static/js/dag/details/taskInstance/taskActions/ActionButton.tsx
similarity index 79%
rename from airflow/www/static/js/dag/details/taskInstance/taskActions/ActionButton.jsx
rename to airflow/www/static/js/dag/details/taskInstance/taskActions/ActionButton.tsx
index d25db9eb53..09c189b49c 100644
--- a/airflow/www/static/js/dag/details/taskInstance/taskActions/ActionButton.jsx
+++ b/airflow/www/static/js/dag/details/taskInstance/taskActions/ActionButton.tsx
@@ -18,7 +18,7 @@
*/
import React from 'react';
-import { Button } from '@chakra-ui/react';
+import { Button, ButtonProps } from '@chakra-ui/react';
const titleMap = {
past: 'Also include past task instances when clearing this one',
@@ -29,8 +29,11 @@ const titleMap = {
failed: 'Only consider failed task instances when clearing this one',
};
-const ActionButton = ({ name, ...rest }) => (
- <Button title={titleMap[name.toLowerCase()]} {...rest}>{name}</Button>
+type KeysOfTitleMap = keyof (typeof titleMap);
+
+type Props = ButtonProps & { name: Capitalize<KeysOfTitleMap> } ;
+const ActionButton = ({ name, ...rest }: Props) => (
+ <Button title={titleMap[name.toLowerCase() as KeysOfTitleMap]} {...rest}>{name}</Button>
);
export default ActionButton;
diff --git a/airflow/www/static/js/dag/details/taskInstance/taskActions/index.tsx b/airflow/www/static/js/dag/details/taskInstance/taskActions/index.tsx
new file mode 100644
index 0000000000..2ba97a1e54
--- /dev/null
+++ b/airflow/www/static/js/dag/details/taskInstance/taskActions/index.tsx
@@ -0,0 +1,76 @@
+/*!
+ * 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 {
+ Box,
+ VStack,
+ Divider,
+ StackDivider,
+ Text,
+} from '@chakra-ui/react';
+
+import type { CommonActionProps } from './types';
+import RunAction from './Run';
+import ClearAction from './Clear';
+import MarkFailedAction from './MarkFailed';
+import MarkSuccessAction from './MarkSuccess';
+
+type Props = {
+ title: string;
+} & CommonActionProps;
+
+const TaskActions = ({
+ title, runId, taskId, dagId, executionDate, mapIndexes,
+}: Props) => (
+ <Box my={3}>
+ <Text as="strong">{title}</Text>
+ <Divider my={2} />
+ <VStack justifyContent="center" divider={<StackDivider my={3} />}>
+ <RunAction
+ runId={runId}
+ taskId={taskId}
+ dagId={dagId}
+ mapIndexes={mapIndexes}
+ />
+ <ClearAction
+ runId={runId}
+ taskId={taskId}
+ dagId={dagId}
+ executionDate={executionDate}
+ mapIndexes={mapIndexes}
+ />
+ <MarkFailedAction
+ runId={runId}
+ taskId={taskId}
+ dagId={dagId}
+ mapIndexes={mapIndexes}
+ />
+ <MarkSuccessAction
+ runId={runId}
+ taskId={taskId}
+ dagId={dagId}
+ mapIndexes={mapIndexes}
+ />
+ </VStack>
+ <Divider my={2} />
+ </Box>
+);
+
+export default TaskActions;
diff --git a/airflow/www/static/js/dag/details/BreadcrumbText.tsx b/airflow/www/static/js/dag/details/taskInstance/taskActions/types.tsx
similarity index 66%
copy from airflow/www/static/js/dag/details/BreadcrumbText.tsx
copy to airflow/www/static/js/dag/details/taskInstance/taskActions/types.tsx
index 85d5f6dec5..c5bacf3302 100644
--- a/airflow/www/static/js/dag/details/BreadcrumbText.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/taskActions/types.tsx
@@ -17,22 +17,12 @@
* under the License.
*/
-import React from 'react';
-import {
- Box,
- Heading,
-} from '@chakra-ui/react';
+import type { Dag, DagRun, TaskInstance } from 'src/types';
-interface Props {
- label: string;
- value: React.ReactNode;
+export interface CommonActionProps {
+ runId: DagRun['runId'],
+ taskId: TaskInstance['taskId'] | null,
+ dagId: Dag['id'],
+ executionDate: DagRun['executionDate'],
+ mapIndexes: number[]
}
-
-const BreadcrumbText = ({ label, value }: Props) => (
- <Box position="relative">
- <Heading as="h5" size="sm" color="gray.300" position="absolute" top="-12px">{label}</Heading>
- <Heading as="h3" size="md">{value}</Heading>
- </Box>
-);
-
-export default BreadcrumbText;
diff --git a/airflow/www/static/js/dag/useSelection.test.tsx b/airflow/www/static/js/dag/useSelection.test.tsx
index 3cfe61d5db..d7959e37dc 100644
--- a/airflow/www/static/js/dag/useSelection.test.tsx
+++ b/airflow/www/static/js/dag/useSelection.test.tsx
@@ -38,17 +38,21 @@ describe('Test useSelection hook', () => {
selected: {
runId,
taskId,
+ mapIndex,
},
} = result.current;
expect(runId).toBeNull();
expect(taskId).toBeNull();
+ expect(mapIndex).toBeNull();
});
test.each([
- { taskId: 'task_1', runId: 'run_1' },
- { runId: 'run_1', taskId: null },
- { taskId: 'task_1', runId: null },
+ { taskId: 'task_1', runId: 'run_1', mapIndex: 2 },
+ { taskId: null, runId: 'run_1', mapIndex: null },
+ { taskId: 'task_2', runId: null, mapIndex: 1 },
+ { taskId: 'task_3', runId: null, mapIndex: -1 },
+ { taskId: 'task_4', runId: null, mapIndex: 0 },
])('Test onSelect() and clearSelection()', async (selected) => {
const { result } = renderHook(() => useSelection(), { wrapper: Wrapper });
@@ -58,6 +62,7 @@ describe('Test useSelection hook', () => {
expect(result.current.selected.taskId).toBe(selected.taskId);
expect(result.current.selected.runId).toBe(selected.runId);
+ expect(result.current.selected.mapIndex).toBe(selected.mapIndex);
// clearSelection
await act(async () => {
@@ -66,5 +71,6 @@ describe('Test useSelection hook', () => {
expect(result.current.selected.taskId).toBeNull();
expect(result.current.selected.runId).toBeNull();
+ expect(result.current.selected.mapIndex).toBeNull();
});
});
diff --git a/airflow/www/static/js/dag/useSelection.ts b/airflow/www/static/js/dag/useSelection.ts
index c4f5290b11..790e584e83 100644
--- a/airflow/www/static/js/dag/useSelection.ts
+++ b/airflow/www/static/js/dag/useSelection.ts
@@ -21,10 +21,12 @@ import { useSearchParams } from 'react-router-dom';
const RUN_ID = 'dag_run_id';
const TASK_ID = 'task_id';
+const MAP_INDEX = 'map_index';
export interface SelectionProps {
runId?: string | null ;
taskId?: string | null;
+ mapIndex?: number | null;
}
const useSelection = () => {
@@ -34,10 +36,11 @@ const useSelection = () => {
const clearSelection = () => {
searchParams.delete(RUN_ID);
searchParams.delete(TASK_ID);
+ searchParams.delete(MAP_INDEX);
setSearchParams(searchParams);
};
- const onSelect = ({ runId, taskId }: SelectionProps) => {
+ const onSelect = ({ runId, taskId, mapIndex }: SelectionProps) => {
const params = new URLSearchParams(searchParams);
if (runId) params.set(RUN_ID, runId);
@@ -46,16 +49,22 @@ const useSelection = () => {
if (taskId) params.set(TASK_ID, taskId);
else params.delete(TASK_ID);
+ if (mapIndex || mapIndex === 0) params.set(MAP_INDEX, mapIndex.toString());
+ else params.delete(MAP_INDEX);
+
setSearchParams(params);
};
const runId = searchParams.get(RUN_ID);
const taskId = searchParams.get(TASK_ID);
+ const mapIndexParam = searchParams.get(MAP_INDEX);
+ const mapIndex = mapIndexParam !== null ? parseInt(mapIndexParam, 10) : null;
return {
selected: {
runId,
taskId,
+ mapIndex,
},
clearSelection,
onSelect,
diff --git a/airflow/www/static/js/types/index.ts b/airflow/www/static/js/types/index.ts
index 60a8c8a9bb..6918320f15 100644
--- a/airflow/www/static/js/types/index.ts
+++ b/airflow/www/static/js/types/index.ts
@@ -63,6 +63,7 @@ interface TaskInstance {
mappedStates?: {
[key: string]: number;
},
+ mapIndex?: number;
tryNumber?: number;
}
diff --git a/airflow/www/templates/airflow/dag.html b/airflow/www/templates/airflow/dag.html
index 20a2f06e54..860dfaeb8f 100644
--- a/airflow/www/templates/airflow/dag.html
+++ b/airflow/www/templates/airflow/dag.html
@@ -72,6 +72,7 @@
<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') }}">
<meta name="upstream_dataset_events_api" content="{{ url_for('/api/v1.airflow_api_connexion_endpoints_dag_run_endpoint_get_upstream_dataset_events', dag_id=dag.dag_id, dag_run_id='_DAG_RUN_ID_') }}">
+ <meta name="task_instance_api" content="{{ url_for('/api/v1.airflow_api_connexion_endpoints_task_instance_endpoint_get_task_instance', 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() }}">