Add task duration plot across dagruns (#40755)

* Initial commit to display task duration across all task instances.

* Switch to datasets with custom labeling of hh:mm:ss format for time. Add support to click on a data point and get to details page.

* Format value in tooltip.

* Add show bar chart option.

* Ignore ts check for moment.
diff --git a/airflow/www/static/js/dag/details/index.tsx b/airflow/www/static/js/dag/details/index.tsx
index 56f5b5a..da2461d 100644
--- a/airflow/www/static/js/dag/details/index.tsx
+++ b/airflow/www/static/js/dag/details/index.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import React, { useCallback, useEffect } from "react";
+import React, { useCallback, useEffect, useState } from "react";
 import {
   Flex,
   Divider,
@@ -28,7 +28,9 @@
   Tab,
   Text,
   Button,
+  Checkbox,
 } from "@chakra-ui/react";
+import InfoTooltip from "src/components/InfoTooltip";
 import { useSearchParams } from "react-router-dom";
 
 import useSelection from "src/dag/useSelection";
@@ -64,6 +66,7 @@
 import ClearInstance from "./taskInstance/taskActions/ClearInstance";
 import MarkInstanceAs from "./taskInstance/taskActions/MarkInstanceAs";
 import XcomCollection from "./taskInstance/Xcom";
+import AllTaskDuration from "./task/AllTaskDuration";
 import TaskDetails from "./task";
 import EventLog from "./EventLog";
 import RunDuration from "./dag/RunDuration";
@@ -98,8 +101,9 @@
     case "run_duration":
       return 5;
     case "xcom":
-    case "calendar":
+    case "task_duration":
       return 6;
+    case "calendar":
     case "rendered_k8s":
       return 7;
     case "details":
@@ -138,10 +142,11 @@
       if (!runId && !taskId) return "run_duration";
       return undefined;
     case 6:
-      if (!runId && !taskId) return "calendar";
+      if (!runId && !taskId) return "task_duration";
       if (isTaskInstance) return "xcom";
       return undefined;
     case 7:
+      if (!runId && !taskId) return "calendar";
       if (isTaskInstance && isK8sExecutor) return "rendered_k8s";
       return undefined;
     default:
@@ -172,6 +177,7 @@
   const children = group?.children;
   const isMapped = group?.isMapped;
   const isGroup = !!children;
+  const [showBar, setShowBar] = useState(false);
 
   const isMappedTaskSummary = !!(
     taskId &&
@@ -341,6 +347,14 @@
           )}
           {isDag && (
             <Tab>
+              <MdHourglassBottom size={16} />
+              <Text as="strong" ml={1}>
+                Task Duration
+              </Text>
+            </Tab>
+          )}
+          {isDag && (
+            <Tab>
               <MdEvent size={16} />
               <Text as="strong" ml={1}>
                 Calendar
@@ -453,6 +467,21 @@
             </TabPanel>
           )}
           {isDag && (
+            <TabPanel height="80%">
+              <Flex justifyContent="right" pr="30px">
+                <Checkbox
+                  isChecked={showBar}
+                  onChange={() => setShowBar(!showBar)}
+                  size="lg"
+                >
+                  Show Bar Chart
+                </Checkbox>
+                <InfoTooltip label="Show bar chart" size={16} />
+              </Flex>
+              <AllTaskDuration showBar={showBar} />
+            </TabPanel>
+          )}
+          {isDag && (
             <TabPanel height="100%" width="100%" overflow="auto">
               <Calendar />
             </TabPanel>
diff --git a/airflow/www/static/js/dag/details/task/AllTaskDuration.tsx b/airflow/www/static/js/dag/details/task/AllTaskDuration.tsx
new file mode 100644
index 0000000..c5babb8
--- /dev/null
+++ b/airflow/www/static/js/dag/details/task/AllTaskDuration.tsx
@@ -0,0 +1,167 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* global moment */
+
+import React from "react";
+import type { SeriesOption } from "echarts";
+
+import { useSearchParams } from "react-router-dom";
+import useSelection from "src/dag/useSelection";
+import { useGridData } from "src/api";
+import { startCase } from "lodash";
+import { getDuration, defaultFormat } from "src/datetime_utils";
+import ReactECharts, { ReactEChartsProps } from "src/components/ReactECharts";
+import URLSearchParamsWrapper from "src/utils/URLSearchParamWrapper";
+
+const TAB_PARAM = "tab";
+
+interface Props {
+  showBar: boolean;
+}
+
+const AllTaskDuration = ({ showBar }: Props) => {
+  const { onSelect } = useSelection();
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const {
+    data: { dagRuns, groups, ordering },
+  } = useGridData();
+  const runIds: Array<string> = [];
+  const source: Record<string, Array<string | number>> = {};
+
+  dagRuns.forEach((dr) => {
+    runIds.push(dr.runId!!);
+  });
+
+  source.runId = runIds;
+  const seriesData: Array<SeriesOption> = [];
+  const legendData: Array<string> = [];
+  const orderingLabel = ordering[0] || ordering[1] || "startDate";
+
+  groups.children?.forEach((children) => {
+    if (children.id === null) {
+      return;
+    }
+
+    const taskId = children.id;
+    legendData.push(taskId);
+
+    if (showBar) {
+      seriesData.push({
+        name: taskId,
+        type: "bar",
+        stack: "x",
+      } as SeriesOption);
+    } else {
+      seriesData.push({
+        name: taskId,
+        type: "line",
+      } as SeriesOption);
+    }
+
+    source[taskId] = children.instances.map((instance) => {
+      // @ts-ignore
+      const runDuration = moment
+        .duration(
+          instance.startDate
+            ? getDuration(instance.startDate, instance?.endDate)
+            : 0
+        )
+        .asSeconds();
+
+      return runDuration;
+    });
+  });
+
+  const dimensions = ["runId"].concat(legendData);
+
+  // @ts-ignore
+  function formatTooltip(value) {
+    // @ts-ignore
+    return moment.utc(value * 1000).format("HH[h]:mm[m]:ss[s]");
+  }
+
+  const option: ReactEChartsProps["option"] = {
+    legend: {
+      orient: "horizontal",
+      icon: "circle",
+      data: legendData,
+    },
+    tooltip: {
+      trigger: "axis",
+      valueFormatter: formatTooltip,
+    },
+    // @ts-ignore
+    dataset: {
+      dimensions,
+      source,
+    },
+    series: seriesData,
+    xAxis: {
+      type: "category",
+      show: true,
+      axisLabel: {
+        formatter: (runId: string) => {
+          const dagRun = dagRuns.find((dr) => dr.runId === runId);
+          if (!dagRun || !dagRun[orderingLabel]) return runId;
+          // @ts-ignore
+          return moment(dagRun[orderingLabel]).format(defaultFormat);
+        },
+      },
+      name: startCase(orderingLabel),
+      nameLocation: "end",
+      nameGap: 0,
+      nameTextStyle: {
+        align: "right",
+        verticalAlign: "top",
+        padding: [30, 0, 0, 0],
+      },
+    },
+    yAxis: {
+      type: "value",
+      name: `Duration`,
+      axisLabel: {
+        formatter(value: number) {
+          // @ts-ignore
+          const duration = moment.utc(value * 1000);
+          return duration.format("HH[h]:mm[m]:ss[s]");
+        },
+      },
+    },
+  };
+
+  const events = {
+    // @ts-ignore
+    click(params) {
+      const URL_PARAMS = new URLSearchParamsWrapper(searchParams);
+      URL_PARAMS.set(TAB_PARAM, "details");
+      setSearchParams(URL_PARAMS);
+
+      onSelect({
+        taskId: params.seriesName,
+        runId: params.name,
+      });
+    },
+  };
+
+  return <ReactECharts option={option} events={events} />;
+};
+
+export default AllTaskDuration;