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;