This is an automated email from the ASF dual-hosted git repository. phanikumv 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 25132c9180 AIP 64: Add TI try history to Task Instance Details, Logs, and Gantt chart (#40304) 25132c9180 is described below commit 25132c9180f6e8e42846467e7e75a6b564f6380e Author: Brent Bovenzi <br...@astronomer.io> AuthorDate: Thu Jun 20 03:39:46 2024 -0400 AIP 64: Add TI try history to Task Instance Details, Logs, and Gantt chart (#40304) --- airflow/www/static/js/api/index.ts | 2 + airflow/www/static/js/api/useTIHistory.ts | 64 ++++++++++ airflow/www/static/js/dag/StatusBox.tsx | 2 +- .../static/js/dag/details/gantt/GanttTooltip.tsx | 25 ++-- .../static/js/dag/details/gantt/InstanceBar.tsx | 140 +++++++++++++++++++++ airflow/www/static/js/dag/details/gantt/Row.tsx | 121 +++++++----------- .../static/js/dag/details/taskInstance/Details.tsx | 63 +++++++--- .../dag/details/taskInstance/Logs/index.test.tsx | 33 +++++ .../js/dag/details/taskInstance/Logs/index.tsx | 100 +++------------ .../js/dag/details/taskInstance/TrySelector.tsx | 127 +++++++++++++++++++ airflow/www/static/js/types/index.ts | 1 + airflow/www/templates/airflow/dag.html | 1 + 12 files changed, 487 insertions(+), 192 deletions(-) diff --git a/airflow/www/static/js/api/index.ts b/airflow/www/static/js/api/index.ts index b48fdc2dba..487197d608 100644 --- a/airflow/www/static/js/api/index.ts +++ b/airflow/www/static/js/api/index.ts @@ -56,6 +56,7 @@ import useCalendarData from "./useCalendarData"; import useCreateDatasetEvent from "./useCreateDatasetEvent"; import useRenderedK8s from "./useRenderedK8s"; import useTaskDetail from "./useTaskDetail"; +import useTIHistory from "./useTIHistory"; axios.interceptors.request.use((config) => { config.paramsSerializer = { @@ -108,4 +109,5 @@ export { useCreateDatasetEvent, useRenderedK8s, useTaskDetail, + useTIHistory, }; diff --git a/airflow/www/static/js/api/useTIHistory.ts b/airflow/www/static/js/api/useTIHistory.ts new file mode 100644 index 0000000000..60e29e351e --- /dev/null +++ b/airflow/www/static/js/api/useTIHistory.ts @@ -0,0 +1,64 @@ +/*! + * 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, { AxiosResponse } from "axios"; +import { useQuery } from "react-query"; +import { useAutoRefresh } from "src/context/autorefresh"; +import type { TaskInstance } from "src/types/api-generated"; + +import { getMetaValue } from "src/utils"; + +interface Props { + dagId: string; + runId: string; + taskId: string; + mapIndex?: number; + enabled?: boolean; +} + +export default function useTIHistory({ + dagId, + runId, + taskId, + mapIndex = -1, + enabled, +}: Props) { + const { isRefreshOn } = useAutoRefresh(); + return useQuery( + ["tiHistory", dagId, runId, taskId, mapIndex], + () => { + const tiHistoryUrl = getMetaValue("ti_history_url"); + + const params = { + dag_id: dagId, + run_id: runId, + task_id: taskId, + map_index: mapIndex, + }; + + return axios.get<AxiosResponse, Partial<TaskInstance>[]>(tiHistoryUrl, { + params, + }); + }, + { + enabled, + refetchInterval: isRefreshOn && (autoRefreshInterval || 1) * 1000, + } + ); +} diff --git a/airflow/www/static/js/dag/StatusBox.tsx b/airflow/www/static/js/dag/StatusBox.tsx index f84ce9c9e8..85a84dc133 100644 --- a/airflow/www/static/js/dag/StatusBox.tsx +++ b/airflow/www/static/js/dag/StatusBox.tsx @@ -56,7 +56,7 @@ export const StatusWithNotes = ({ }; interface SimpleStatusProps extends BoxProps { - state: TaskState; + state: TaskState | undefined; } export const SimpleStatus = ({ state, ...rest }: SimpleStatusProps) => ( <Box diff --git a/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx b/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx index 465eaa930e..1dd8665495 100644 --- a/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx +++ b/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx @@ -21,10 +21,15 @@ import React from "react"; import { Box, Text } from "@chakra-ui/react"; import { getDuration, formatDuration } from "src/datetime_utils"; import Time from "src/components/Time"; -import type { Task, TaskInstance } from "src/types"; +import type { Task, API } from "src/types"; + +type Instance = Pick< + API.TaskInstance, + "startDate" | "endDate" | "tryNumber" | "queuedWhen" +>; interface Props { - instance: TaskInstance; + instance: Instance; task: Task; } @@ -35,20 +40,18 @@ const GanttTooltip = ({ task, instance }: Props) => { // Calculate durations in ms const taskDuration = getDuration(instance?.startDate, instance?.endDate); const queuedDuration = - instance?.queuedDttm && - (instance?.startDate ? instance.queuedDttm < instance.startDate : true) - ? getDuration(instance.queuedDttm, instance?.startDate) + instance?.queuedWhen && + (instance?.startDate ? instance.queuedWhen < instance.startDate : true) + ? getDuration(instance.queuedWhen, instance?.startDate) : 0; return ( <Box> <Text> Task{isGroup ? " Group" : ""}: {task.label} </Text> - {!!instance?.tryNumber && instance.tryNumber > 1 && ( - <Text>Try Number: {instance.tryNumber}</Text> - )} + {!!instance?.tryNumber && <Text>Try Number: {instance.tryNumber}</Text>} <br /> - {instance?.queuedDttm && ( + {instance?.queuedWhen && ( <Text> {isMappedOrGroupSummary && "Total "}Queued Duration:{" "} {formatDuration(queuedDuration)} @@ -59,10 +62,10 @@ const GanttTooltip = ({ task, instance }: Props) => { {formatDuration(taskDuration)} </Text> <br /> - {instance?.queuedDttm && ( + {instance?.queuedWhen && ( <Text> {isMappedOrGroupSummary && "Earliest "}Queued At:{" "} - <Time dateTime={instance?.queuedDttm} /> + <Time dateTime={instance?.queuedWhen} /> </Text> )} {instance?.startDate && ( diff --git a/airflow/www/static/js/dag/details/gantt/InstanceBar.tsx b/airflow/www/static/js/dag/details/gantt/InstanceBar.tsx new file mode 100644 index 0000000000..8279cbff3c --- /dev/null +++ b/airflow/www/static/js/dag/details/gantt/InstanceBar.tsx @@ -0,0 +1,140 @@ +/*! + * 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 { Tooltip, Flex } from "@chakra-ui/react"; +import useSelection from "src/dag/useSelection"; +import { getDuration } from "src/datetime_utils"; +import { SimpleStatus } from "src/dag/StatusBox"; +import { useContainerRef } from "src/context/containerRef"; +import { hoverDelay } from "src/utils"; +import type { Task } from "src/types"; +import type { TaskInstance } from "src/types/api-generated"; +import GanttTooltip from "./GanttTooltip"; + +type Instance = Pick< + TaskInstance, + | "startDate" + | "endDate" + | "tryNumber" + | "queuedWhen" + | "dagRunId" + | "state" + | "taskId" +>; + +interface Props { + ganttWidth?: number; + task: Task; + instance: Instance; + ganttStartDate?: string | null; + ganttEndDate?: string | null; +} + +const InstanceBar = ({ + ganttWidth = 500, + task, + instance, + ganttStartDate, + ganttEndDate, +}: Props) => { + const { onSelect } = useSelection(); + const containerRef = useContainerRef(); + + const runDuration = getDuration(ganttStartDate, ganttEndDate); + const { queuedWhen } = instance; + + const hasValidQueuedDttm = + !!queuedWhen && + (instance?.startDate && queuedWhen + ? queuedWhen < instance.startDate + : true); + + // Calculate durations in ms + const taskDuration = getDuration(instance?.startDate, instance?.endDate); + const queuedDuration = hasValidQueuedDttm + ? getDuration(queuedWhen, instance?.startDate) + : 0; + const taskStartOffset = hasValidQueuedDttm + ? getDuration(ganttStartDate, queuedWhen || instance?.startDate) + : getDuration(ganttStartDate, instance?.startDate); + + // Percent of each duration vs the overall dag run + const taskDurationPercent = taskDuration / runDuration; + const taskStartOffsetPercent = taskStartOffset / runDuration; + const queuedDurationPercent = queuedDuration / runDuration; + + // Calculate the pixel width of the queued and task bars and the position in the graph + // Min width should be 5px + let width = ganttWidth * taskDurationPercent; + if (width < 5) width = 5; + let queuedWidth = hasValidQueuedDttm ? ganttWidth * queuedDurationPercent : 0; + if (hasValidQueuedDttm && queuedWidth < 5) queuedWidth = 5; + const offsetMargin = taskStartOffsetPercent * ganttWidth; + + if (!instance) return null; + + return ( + <Tooltip + label={<GanttTooltip task={task} instance={instance} />} + hasArrow + portalProps={{ containerRef }} + placement="top" + openDelay={hoverDelay} + > + <Flex + width={`${width + queuedWidth}px`} + position="absolute" + top="4px" + left={`${offsetMargin}px`} + cursor="pointer" + pointerEvents="auto" + onClick={() => { + onSelect({ + runId: instance.dagRunId, + taskId: instance.taskId, + }); + }} + > + {instance.state !== "queued" && hasValidQueuedDttm && ( + <SimpleStatus + state="queued" + width={`${queuedWidth}px`} + borderRightRadius={0} + // The normal queued color is too dark when next to the actual task's state + opacity={0.6} + /> + )} + <SimpleStatus + state={ + !instance.state || instance?.state === "none" + ? null + : instance.state + } + width={`${width}px`} + borderLeftRadius={ + instance.state !== "queued" && hasValidQueuedDttm ? 0 : undefined + } + /> + </Flex> + </Tooltip> + ); +}; + +export default InstanceBar; diff --git a/airflow/www/static/js/dag/details/gantt/Row.tsx b/airflow/www/static/js/dag/details/gantt/Row.tsx index 1526e1713f..56d1dafe7a 100644 --- a/airflow/www/static/js/dag/details/gantt/Row.tsx +++ b/airflow/www/static/js/dag/details/gantt/Row.tsx @@ -18,14 +18,13 @@ */ import React from "react"; -import { Box, Tooltip, Flex } from "@chakra-ui/react"; +import { Box } from "@chakra-ui/react"; import useSelection from "src/dag/useSelection"; -import { getDuration } from "src/datetime_utils"; -import { SimpleStatus } from "src/dag/StatusBox"; -import { useContainerRef } from "src/context/containerRef"; -import { hoverDelay } from "src/utils"; +import { boxSize } from "src/dag/StatusBox"; +import { getMetaValue } from "src/utils"; import type { Task } from "src/types"; -import GanttTooltip from "./GanttTooltip"; +import { useTIHistory } from "src/api"; +import InstanceBar from "./InstanceBar"; interface Props { ganttWidth?: number; @@ -35,6 +34,8 @@ interface Props { ganttEndDate?: string | null; } +const dagId = getMetaValue("dag_id"); + const Row = ({ ganttWidth = 500, openGroupIds, @@ -44,42 +45,19 @@ const Row = ({ }: Props) => { const { selected: { runId, taskId }, - onSelect, } = useSelection(); - const containerRef = useContainerRef(); - - const runDuration = getDuration(ganttStartDate, ganttEndDate); const instance = task.instances.find((ti) => ti.runId === runId); - const isSelected = taskId === instance?.taskId; - const hasValidQueuedDttm = - !!instance?.queuedDttm && - (instance?.startDate && instance?.queuedDttm - ? instance.queuedDttm < instance.startDate - : true); - const isOpen = openGroupIds.includes(task.id || ""); - - // Calculate durations in ms - const taskDuration = getDuration(instance?.startDate, instance?.endDate); - const queuedDuration = hasValidQueuedDttm - ? getDuration(instance?.queuedDttm, instance?.startDate) - : 0; - const taskStartOffset = hasValidQueuedDttm - ? getDuration(ganttStartDate, instance?.queuedDttm || instance?.startDate) - : getDuration(ganttStartDate, instance?.startDate); - // Percent of each duration vs the overall dag run - const taskDurationPercent = taskDuration / runDuration; - const taskStartOffsetPercent = taskStartOffset / runDuration; - const queuedDurationPercent = queuedDuration / runDuration; + const { data: tiHistory } = useTIHistory({ + dagId, + taskId: task.id || "", + runId: runId || "", + enabled: !!(instance?.tryNumber && instance?.tryNumber > 1) && !!task.id, // Only try to look up task tries if try number > 1 + }); - // Calculate the pixel width of the queued and task bars and the position in the graph - // Min width should be 5px - let width = ganttWidth * taskDurationPercent; - if (width < 5) width = 5; - let queuedWidth = hasValidQueuedDttm ? ganttWidth * queuedDurationPercent : 0; - if (hasValidQueuedDttm && queuedWidth < 5) queuedWidth = 5; - const offsetMargin = taskStartOffsetPercent * ganttWidth; + const isSelected = taskId === instance?.taskId; + const isOpen = openGroupIds.includes(task.id || ""); return ( <div> @@ -88,50 +66,35 @@ const Row = ({ borderBottomWidth={1} borderBottomColor={!!task.children && isOpen ? "gray.400" : "gray.200"} bg={isSelected ? "blue.100" : "inherit"} + position="relative" + width={ganttWidth} + height={`${boxSize + 9}px`} > - {instance ? ( - <Tooltip - label={<GanttTooltip task={task} instance={instance} />} - hasArrow - portalProps={{ containerRef }} - placement="top" - openDelay={hoverDelay} - > - <Flex - width={`${width + queuedWidth}px`} - cursor="pointer" - pointerEvents="auto" - marginLeft={`${offsetMargin}px`} - onClick={() => { - onSelect({ - runId: instance.runId, - taskId: instance.taskId, - }); - }} - > - {instance.state !== "queued" && hasValidQueuedDttm && ( - <SimpleStatus - state="queued" - width={`${queuedWidth}px`} - borderRightRadius={0} - // The normal queued color is too dark when next to the actual task's state - opacity={0.6} - /> - )} - <SimpleStatus - state={instance.state} - width={`${width}px`} - borderLeftRadius={ - instance.state !== "queued" && hasValidQueuedDttm - ? 0 - : undefined - } - /> - </Flex> - </Tooltip> - ) : ( - <Box height="10px" /> + {!!instance && ( + <InstanceBar + instance={{ ...instance, queuedWhen: instance.queuedDttm }} + task={task} + ganttWidth={ganttWidth} + ganttStartDate={ganttStartDate} + ganttEndDate={ganttEndDate} + /> )} + {(tiHistory || []) + .filter( + (ti) => + ti.startDate !== instance?.startDate && // @ts-ignore + moment(ti.startDate).isAfter(ganttStartDate) + ) + .map((ti) => ( + <InstanceBar + key={`${taskId}-${ti.tryNumber}`} + instance={ti} + task={task} + ganttWidth={ganttWidth} + ganttStartDate={ganttStartDate} + ganttEndDate={ganttEndDate} + /> + ))} </Box> {isOpen && !!task.children && diff --git a/airflow/www/static/js/dag/details/taskInstance/Details.tsx b/airflow/www/static/js/dag/details/taskInstance/Details.tsx index 17d5f13baa..4e39b11c02 100644 --- a/airflow/www/static/js/dag/details/taskInstance/Details.tsx +++ b/airflow/www/static/js/dag/details/taskInstance/Details.tsx @@ -17,11 +17,12 @@ * under the License. */ -import React from "react"; +import React, { useState } from "react"; import { Text, Flex, Table, Tbody, Tr, Td, Code, Box } from "@chakra-ui/react"; import { snakeCase } from "lodash"; -import { getGroupAndMapSummary } from "src/utils"; +import { useTIHistory } from "src/api"; +import { getGroupAndMapSummary, getMetaValue } from "src/utils"; import { getDuration, formatDuration } from "src/datetime_utils"; import { SimpleStatus } from "src/dag/StatusBox"; import Time from "src/components/Time"; @@ -32,6 +33,7 @@ import type { TaskInstance as GridTaskInstance, TaskState, } from "src/types"; +import TrySelector from "./TrySelector"; interface Props { gridInstance?: GridTaskInstance; @@ -39,23 +41,44 @@ interface Props { group?: Task | null; } +const dagId = getMetaValue("dag_id"); + const Details = ({ gridInstance, taskInstance, group }: Props) => { const isGroup = !!group?.children; const summary: React.ReactNode[] = []; + const { + mapIndex, + runId, + taskId, + tryNumber: finalTryNumber, + } = gridInstance || {}; + + const { data: tiHistory } = useTIHistory({ + dagId, + taskId: taskId || "", + runId: runId || "", + mapIndex, + enabled: !!(finalTryNumber && finalTryNumber > 1) && !!taskId, // Only try to look up task tries if try number > 1 + }); + + const [selectedTryNumber, setSelectedTryNumber] = useState(0); + + const instance = + selectedTryNumber !== finalTryNumber + ? tiHistory?.find((ti) => ti.tryNumber === selectedTryNumber) + : gridInstance || taskInstance; + const state = - gridInstance?.state || - (taskInstance?.state === "none" ? null : taskInstance?.state) || + instance?.state || + (instance?.state === "none" ? null : instance?.state) || null; const isMapped = group?.isMapped; - const runId = gridInstance?.runId || taskInstance?.dagRunId; - const startDate = gridInstance?.startDate || taskInstance?.startDate; - const endDate = gridInstance?.endDate || taskInstance?.endDate; - const taskId = gridInstance?.taskId || taskInstance?.taskId; - const mapIndex = gridInstance?.mapIndex || taskInstance?.mapIndex; + const startDate = instance?.startDate; + const endDate = instance?.endDate; const executor = taskInstance?.executor || "<default>"; - const operator = group?.operator || taskInstance?.operator; + const operator = taskInstance?.operator || group?.operator; const mappedStates = !taskInstance ? gridInstance?.mappedStates : undefined; @@ -92,6 +115,8 @@ const Details = ({ gridInstance, taskInstance, group }: Props) => { }); } + const isTaskInstance = !isGroup && !(isMapped && mapIndex === undefined); + const taskIdTitle = isGroup ? "Task Group ID" : "Task ID"; const isStateFinal = state && @@ -100,9 +125,15 @@ const Details = ({ gridInstance, taskInstance, group }: Props) => { return ( <Box mt={3} flexGrow={1}> - <Text as="strong" mb={3}> - Task Instance Details - </Text> + {isTaskInstance && ( + <TrySelector + taskId={taskId} + runId={runId} + mapIndex={mapIndex} + selectedTryNumber={selectedTryNumber || finalTryNumber} + onSelectTryNumber={setSelectedTryNumber} + /> + )} <Table variant="striped"> <Tbody> {group?.tooltip && ( @@ -173,12 +204,6 @@ const Details = ({ gridInstance, taskInstance, group }: Props) => { <Td>{taskInstance.renderedMapIndex}</Td> </Tr> )} - {!!taskInstance?.tryNumber && ( - <Tr> - <Td>Try Number</Td> - <Td>{taskInstance.tryNumber}</Td> - </Tr> - )} {operator && ( <Tr> <Td>Operator</Td> 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 a10dbe2931..e9c29ed4a8 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 @@ -25,6 +25,9 @@ import type { UseQueryResult } from "react-query"; import * as utils from "src/utils"; import * as useTaskLogModule from "src/api/useTaskLog"; +import * as useTIHistory from "src/api/useTIHistory"; +import * as useTaskInstance from "src/api/useTaskInstance"; +import type { TaskInstance } from "src/types/api-generated"; import Logs from "./index"; @@ -55,11 +58,41 @@ describe("Test Logs Component.", () => { isSuccess: true, } as UseQueryResult<string, unknown>; + const tiReturnValue = { + data: { tryNumber: 2, startDate: "2024-06-18T01:47:51.724946+00:00" }, + isSuccess: true, + } as UseQueryResult<TaskInstance, unknown>; + + const tiHistoryValue = { + data: [ + { + tryNumber: 1, + startDate: "2024-06-17T01:47:51.724946+00:00", + endDate: "2024-06-17T01:50:51.724946+00:00", + state: "failed", + }, + { + tryNumber: 2, + startDate: "2024-06-18T01:47:51.724946+00:00", + endDate: "2024-06-18T01:50:51.724946+00:00", + state: "failed", + }, + ], + isSuccess: true, + } as UseQueryResult<Partial<TaskInstance>[], unknown>; + beforeEach(() => { useTaskLogMock = jest .spyOn(useTaskLogModule, "default") .mockImplementation(() => returnValue); window.HTMLElement.prototype.scrollIntoView = jest.fn(); + + jest + .spyOn(useTaskInstance, "default") + .mockImplementation(() => tiReturnValue); + jest + .spyOn(useTIHistory, "default") + .mockImplementation(() => tiHistoryValue); }); test("Test Logs Content", () => { 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 8510bd3285..95553d5499 100644 --- a/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx +++ b/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx @@ -18,16 +18,7 @@ */ import React, { useState, useEffect, useMemo } from "react"; -import { - Text, - Box, - Flex, - Button, - Checkbox, - Icon, - Spinner, - Select, -} from "@chakra-ui/react"; +import { Text, Box, Flex, Checkbox, Icon, Spinner } from "@chakra-ui/react"; import { MdWarning } from "react-icons/md"; import { getMetaValue } from "src/utils"; @@ -41,6 +32,7 @@ import URLSearchParamsWrapper from "src/utils/URLSearchParamWrapper"; import LogLink from "./LogLink"; import { LogLevel, logLevelColorMapping, parseLogs } from "./utils"; import LogBlock from "./LogBlock"; +import TrySelector from "../TrySelector"; interface LogLevelOption { label: LogLevel; @@ -58,26 +50,6 @@ const showExternalLogRedirect = const externalLogName = getMetaValue("external_log_name"); const logUrl = getMetaValue("log_url"); -const getLinkIndexes = ( - tryNumber: number | undefined -): Array<Array<number>> => { - const internalIndexes: Array<number> = []; - const externalIndexes: Array<number> = []; - - if (tryNumber) { - [...Array(tryNumber)].forEach((_, index) => { - const tryNum = index + 1; - if (showExternalLogRedirect) { - externalIndexes.push(tryNum); - } else { - internalIndexes.push(tryNum); - } - }); - } - - return [internalIndexes, externalIndexes]; -}; - const logLevelOptions: Array<LogLevelOption> = Object.values(LogLevel).map( (value): LogLevelOption => ({ label: value, @@ -105,10 +77,7 @@ const Logs = ({ tryNumber, state, }: Props) => { - const [internalIndexes, externalIndexes] = getLinkIndexes(tryNumber); - const [selectedTryNumber, setSelectedTryNumber] = useState< - number | undefined - >(); + const [selectedTryNumber, setSelectedTryNumber] = useState(tryNumber || 1); const [wrap, setWrap] = useState(getMetaValue("default_wrap") === "True"); const [logLevelFilters, setLogLevelFilters] = useState<Array<LogLevelOption>>( [] @@ -119,13 +88,12 @@ const Logs = ({ const [unfoldedLogGroups, setUnfoldedLogGroup] = useState<Array<string>>([]); const { timezone } = useTimezone(); - const taskTryNumber = selectedTryNumber || tryNumber || 1; const { data, isLoading } = useTaskLog({ dagId, dagRunId, taskId, mapIndex, - taskTryNumber, + taskTryNumber: selectedTryNumber, state, }); @@ -154,14 +122,11 @@ const Logs = ({ [data, fileSourceFilters, logLevelFilters, timezone, unfoldedLogGroups] ); - const logAttemptDropdownLimit = 10; - const showDropdown = internalIndexes.length > logAttemptDropdownLimit; - useEffect(() => { // Reset fileSourceFilters and selected attempt when changing to // a task that do not have those filters anymore. - if (taskTryNumber > (tryNumber || 1)) { - setSelectedTryNumber(undefined); + if (selectedTryNumber > (tryNumber || 1)) { + setSelectedTryNumber(tryNumber || 1); } if ( @@ -175,16 +140,17 @@ const Logs = ({ ) { setFileSourceFilters([]); } - }, [data, fileSourceFilters, fileSources, taskTryNumber, tryNumber]); + }, [data, fileSourceFilters, fileSources, selectedTryNumber, tryNumber]); return ( <> - {externalLogName && externalIndexes.length > 0 && ( + {showExternalLogRedirect && externalLogName && ( <Box my={1}> <Text>View Logs in {externalLogName} (by attempts):</Text> <Flex flexWrap="wrap"> - {externalIndexes.map((index) => ( + {[...Array(tryNumber || 1)].map((_, index) => ( <LogLink + // eslint-disable-next-line react/no-array-index-key key={index} dagId={dagId} taskId={taskId} @@ -196,45 +162,15 @@ const Logs = ({ </Box> )} <Box> - {!showDropdown && ( - <Box> - <Text as="span"> (by attempts)</Text> - <Flex my={1} justifyContent="space-between"> - <Flex flexWrap="wrap"> - {internalIndexes.map((index) => ( - <Button - key={index} - variant={taskTryNumber === index ? "solid" : "ghost"} - colorScheme="blue" - onClick={() => setSelectedTryNumber(index)} - data-testid={`log-attempt-select-button-${index}`} - > - {index} - </Button> - ))} - </Flex> - </Flex> - </Box> - )} + <TrySelector + taskId={taskId} + runId={dagRunId} + mapIndex={mapIndex} + selectedTryNumber={selectedTryNumber} + onSelectTryNumber={setSelectedTryNumber} + /> <Flex my={1} justifyContent="space-between" flexWrap="wrap"> <Flex alignItems="center" flexGrow={1} mr={10}> - {showDropdown && ( - <Box width="100%" mr={2}> - <Select - size="sm" - placeholder="Select log attempt" - onChange={(e) => { - setSelectedTryNumber(Number(e.target.value)); - }} - > - {internalIndexes.map((index) => ( - <option key={index} value={index}> - {index} - </option> - ))} - </Select> - </Box> - )} <Box width="100%" mr={2}> <MultiSelect size="sm" @@ -313,7 +249,7 @@ const Logs = ({ <LogBlock parsedLogs={parsedLogs} wrap={wrap} - tryNumber={taskTryNumber} + tryNumber={selectedTryNumber} unfoldedGroups={unfoldedLogGroups} setUnfoldedLogGroup={setUnfoldedLogGroup} /> diff --git a/airflow/www/static/js/dag/details/taskInstance/TrySelector.tsx b/airflow/www/static/js/dag/details/taskInstance/TrySelector.tsx new file mode 100644 index 0000000000..f458748cef --- /dev/null +++ b/airflow/www/static/js/dag/details/taskInstance/TrySelector.tsx @@ -0,0 +1,127 @@ +/*! + * 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, Flex, Button, Select } from "@chakra-ui/react"; +import Tooltip from "src/components/Tooltip"; +import { useContainerRef } from "src/context/containerRef"; +import { useTIHistory, useTaskInstance } from "src/api"; +import { getMetaValue } from "src/utils"; +import { SimpleStatus } from "src/dag/StatusBox"; +import { formatDuration, getDuration } from "src/datetime_utils"; + +const dagId = getMetaValue("dag_id"); + +interface Props { + taskId?: string; + runId?: string; + mapIndex?: number; + selectedTryNumber?: number; + onSelectTryNumber?: (tryNumber: number) => void; +} + +const TrySelector = ({ + taskId, + runId, + mapIndex, + selectedTryNumber, + onSelectTryNumber, +}: Props) => { + const containerRef = useContainerRef(); + + const { data: taskInstance } = useTaskInstance({ + dagId, + dagRunId: runId || "", + taskId: taskId || "", + mapIndex, + }); + const finalTryNumber = taskInstance?.tryNumber; + const { data: tiHistory } = useTIHistory({ + dagId, + taskId: taskId || "", + runId: runId || "", + mapIndex, + enabled: !!(finalTryNumber && finalTryNumber > 1) && !!taskId, // Only try to look up task tries if try number > 1 + }); + + if (!finalTryNumber || finalTryNumber <= 1) return null; + + const logAttemptDropdownLimit = 10; + const showDropdown = finalTryNumber > logAttemptDropdownLimit; + + const tries = (tiHistory || []).filter( + (t) => t?.startDate !== taskInstance?.startDate + ); + tries?.push(taskInstance); + + return ( + <Box my={3}> + <Text as="strong">Task Tries</Text> + {!showDropdown && ( + <Flex my={1} flexWrap="wrap"> + {tries.map(({ tryNumber, state, startDate, endDate }, i) => ( + <Tooltip + key={tryNumber} + label={ + <Box> + <Text>Status: {state}</Text> + <Text> + Duration: {formatDuration(getDuration(startDate, endDate))} + </Text> + </Box> + } + hasArrow + portalProps={{ containerRef }} + placement="top" + > + <Button + variant={selectedTryNumber === tryNumber ? "solid" : "ghost"} + colorScheme="blue" + onClick={() => onSelectTryNumber?.(tryNumber || i)} + data-testid={`log-attempt-select-button-${tryNumber}`} + > + <Flex> + <Text mr={2}>{tryNumber}</Text> + <SimpleStatus state={state} /> + </Flex> + </Button> + </Tooltip> + ))} + </Flex> + )} + {showDropdown && ( + <Select + onChange={(e) => { + onSelectTryNumber?.(Number(e.target.value)); + }} + value={selectedTryNumber} + maxWidth="200px" + > + {tries.map(({ tryNumber, state }) => ( + <option key={tryNumber} value={tryNumber}> + {tryNumber}: {state} + </option> + ))} + </Select> + )} + </Box> + ); +}; + +export default TrySelector; diff --git a/airflow/www/static/js/types/index.ts b/airflow/www/static/js/types/index.ts index 79ed13c9bb..573072b5ff 100644 --- a/airflow/www/static/js/types/index.ts +++ b/airflow/www/static/js/types/index.ts @@ -40,6 +40,7 @@ type TaskState = | "upstream_failed" | "skipped" | "deferred" + | "none" | null; interface Dag { diff --git a/airflow/www/templates/airflow/dag.html b/airflow/www/templates/airflow/dag.html index ebdae42f4f..e18a1486bd 100644 --- a/airflow/www/templates/airflow/dag.html +++ b/airflow/www/templates/airflow/dag.html @@ -60,6 +60,7 @@ <meta name="calendar_data_url" content="{{ url_for('Airflow.calendar_data') }}"> <meta name="next_run_datasets_url" content="{{ url_for('Airflow.next_run_datasets', dag_id=dag.dag_id) }}"> <meta name="grid_url" content="{{ url_for('Airflow.grid', dag_id=dag.dag_id) }}"> + <meta name="ti_history_url" content="{{ url_for('Airflow.ti_history') }}"> <meta name="datasets_url" content="{{ url_for('Airflow.datasets') }}"> <meta name="grid_url_no_root" content="{{ url_for('Airflow.grid', dag_id=dag.dag_id, num_runs=num_runs_arg, base_date=base_date_arg) }}"> <meta name="graph_url" content="{{ url_for('Airflow.graph', dag_id=dag.dag_id, root=root) }}">