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


Reply via email to