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 87ac18b0b94 UI: Add a slowest tasks chart to the Dag Overview (#70305)
87ac18b0b94 is described below

commit 87ac18b0b9457d53795585882572a09994438a69
Author: Eddie Roman <[email protected]>
AuthorDate: Tue Jul 28 17:52:48 2026 +0300

    UI: Add a slowest tasks chart to the Dag Overview (#70305)
    
    * UI: Add a slowest tasks chart to the Dag Overview
    
    Finding which tasks dominate a Dag's runtime currently means opening the
    Gantt or duration pages run by run. A ranked median-per-task view on the
    Overview tab answers it at a glance, and stays readable for Dags with
    thousands of tasks since it only shows the top ten.
    
    * UI: Show slowest task instances sorted by duration
    
    Review asked to defer per-task median aggregation to a future /ui
    endpoint. Until then the chart lists individual task instances sorted
    by the API rather than aggregating them client-side.
    
    * UI: Tell repeated tasks apart in the slowest task instances chart
    
    The chart lists individual instances, so one task can take several bars
    with identical labels. Review asked for run timestamp information to
    tell them apart.
---
 .../ui/public/i18n/locales/en/components.json      |   4 +
 .../src/components/SlowestTaskInstancesChart.tsx   | 161 +++++++++++++++++++++
 .../airflow/ui/src/pages/Dag/Overview/Overview.tsx |  31 ++++
 3 files changed, 196 insertions(+)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 837726e376d..dc51b203cd0 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -113,6 +113,10 @@
     "location": "line {{line}} in {{name}}"
   },
   "reparseDag": "Reparse Dag",
+  "slowestTaskInstances": {
+    "empty": "No completed task instances in this range",
+    "title": "Slowest task instances"
+  },
   "sortedAscending": "sorted ascending",
   "sortedDescending": "sorted descending",
   "sortedUnsorted": "unsorted",
diff --git 
a/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx 
b/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx
new file mode 100644
index 00000000000..62f8217b5ee
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx
@@ -0,0 +1,161 @@
+/*!
+ * 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 { Box, Center, Heading, useToken } from "@chakra-ui/react";
+import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, 
type Plugin } from "chart.js";
+import { Bar } from "react-chartjs-2";
+import { useTranslation } from "react-i18next";
+
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import { useTimezone } from "src/context/timezone";
+import { getComputedCSSVariableValue } from "src/theme";
+import {
+  formatDate,
+  getDurationTickStep,
+  renderCompactDuration,
+  renderDuration,
+} from "src/utils/datetimeUtils";
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip);
+
+const CHART_HEIGHT = "340px";
+const RUN_LABEL_FORMAT = "MMM DD HH:mm";
+
+export const SlowestTaskInstancesChart = ({
+  taskInstances,
+}: {
+  readonly taskInstances: Array<TaskInstanceResponse>;
+}) => {
+  const { t: translate } = useTranslation(["components", "common"]);
+  const { selectedTimezone } = useTimezone();
+  const [labelColorToken, fallbackColorToken] = useToken("colors", 
["fg.muted", "gray.solid"]);
+
+  const states = taskInstances.map((taskInstance) => 
taskInstance.state).filter(Boolean);
+  const stateColorTokens = useToken(
+    "colors",
+    states.map((state) => `${state}.solid`),
+  );
+
+  if (taskInstances.length === 0) {
+    return (
+      <Box data-testid="slowest-task-instances-chart" height={CHART_HEIGHT}>
+        <Heading pb={2} size="sm" textAlign="center">
+          {translate("slowestTaskInstances.title")}
+        </Heading>
+        <Center color="fg.muted" fontSize="sm" height="100%">
+          {translate("slowestTaskInstances.empty")}
+        </Center>
+      </Box>
+    );
+  }
+
+  const stateColorMap: Record<string, string> = {};
+
+  states.forEach((state, index) => {
+    if (state) {
+      stateColorMap[state] = 
getComputedCSSVariableValue(stateColorTokens[index] ?? "oklch(0.5 0 0)");
+    }
+  });
+
+  const fallbackColor = getComputedCSSVariableValue(fallbackColorToken ?? 
"oklch(0.5 0 0)");
+  const durations = taskInstances.map((taskInstance) => taskInstance.duration 
?? 0);
+  const maxDuration = Math.max(...durations, 0);
+
+  const barEndLabels: Plugin<"bar"> = {
+    afterDatasetsDraw: (chart) => {
+      const { ctx } = chart;
+      const meta = chart.getDatasetMeta(0);
+
+      ctx.save();
+      ctx.font = "11px system-ui, sans-serif";
+      ctx.fillStyle = getComputedCSSVariableValue(labelColorToken ?? 
"oklch(0.5 0 0)");
+      ctx.textBaseline = "middle";
+      meta.data.forEach((bar, index) => {
+        const duration = durations[index];
+
+        if (duration !== undefined) {
+          ctx.fillText(renderCompactDuration(duration), bar.x + 6, bar.y);
+        }
+      });
+      ctx.restore();
+    },
+    id: "slowestTaskInstancesBarEndLabels",
+  };
+
+  return (
+    <Box data-testid="slowest-task-instances-chart">
+      <Heading pb={2} size="sm" textAlign="center">
+        {translate("slowestTaskInstances.title")}
+      </Heading>
+      <Box height={CHART_HEIGHT}>
+        <Bar
+          data={{
+            datasets: [
+              {
+                backgroundColor: taskInstances.map(
+                  (taskInstance) =>
+                    (taskInstance.state ? stateColorMap[taskInstance.state] : 
undefined) ?? fallbackColor,
+                ),
+                borderRadius: 3,
+                data: durations,
+              },
+            ],
+            labels: taskInstances.map((taskInstance) => [
+              taskInstance.task_display_name,
+              formatDate(taskInstance.run_after, selectedTimezone, 
RUN_LABEL_FORMAT),
+            ]),
+          }}
+          options={{
+            indexAxis: "y",
+            layout: { padding: { right: 64 } },
+            maintainAspectRatio: false,
+            plugins: {
+              legend: { display: false },
+              tooltip: {
+                callbacks: {
+                  label: (context) => renderDuration(context.parsed.x, false) 
?? "0",
+                  title: ([context]) => {
+                    const taskInstance = context === undefined ? undefined : 
taskInstances[context.dataIndex];
+
+                    return taskInstance === undefined
+                      ? ""
+                      : `${taskInstance.task_display_name} ยท 
${formatDate(taskInstance.run_after, selectedTimezone, RUN_LABEL_FORMAT)}`;
+                  },
+                },
+              },
+            },
+            responsive: true,
+            scales: {
+              x: {
+                beginAtZero: true,
+                ticks: {
+                  callback: (value) =>
+                    renderCompactDuration(typeof value === "number" ? value : 
Number(value)),
+                  stepSize: getDurationTickStep(maxDuration),
+                },
+                title: { align: "end", display: true, text: 
translate("common:duration") },
+              },
+              y: { ticks: { autoSkip: false, font: { size: 11 } } },
+            },
+          }}
+          plugins={[barEndLabels]}
+        />
+      </Box>
+    </Box>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
index 3144be5d380..f301a06b7f3 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
@@ -32,6 +32,7 @@ import {
 import type { ReactAppResponse } from "openapi/requests/types.gen";
 import { AssetEvents } from "src/components/Assets/AssetEvents";
 import { DurationChart } from "src/components/DurationChart";
+import { SlowestTaskInstancesChart } from 
"src/components/SlowestTaskInstancesChart";
 import TimeRangeSelector from "src/components/TimeRangeSelector";
 import { TrendCountButton } from "src/components/TrendCountButton";
 import { dagRunsLimitKey } from "src/constants/localStorage";
@@ -66,6 +67,21 @@ export const Overview = () => {
 
   const failedTaskCount = failedTasks?.total_entries ?? 0;
 
+  const { data: slowestTaskInstancesData, isLoading: 
isLoadingSlowestTaskInstances } =
+    useTaskInstanceServiceGetTaskInstances(
+      {
+        dagId: dagId ?? "",
+        dagRunId: "~",
+        limit: 10,
+        orderBy: ["-duration"],
+        runAfterGte: startDate,
+        runAfterLte: endDate,
+        state: ["success", "failed"],
+      },
+      undefined,
+      { enabled: Boolean(dagId) },
+    );
+
   const [limit] = useLocalStorage<number>(dagRunsLimitKey(dagId ?? ""), 10);
 
   const { data: failedRuns, isLoading: isLoadingFailedRuns } = 
useDagRunServiceGetDagRuns({
@@ -155,6 +171,21 @@ export const Overview = () => {
             />
           )}
         </Box>
+        <Box
+          borderRadius={4}
+          borderStyle="solid"
+          borderWidth={1}
+          flex="1 1 520px"
+          maxWidth="900px"
+          minWidth="320px"
+          p={2}
+        >
+          {isLoadingSlowestTaskInstances ? (
+            <Skeleton height="380px" w="full" />
+          ) : (
+            <SlowestTaskInstancesChart 
taskInstances={slowestTaskInstancesData?.task_instances ?? []} />
+          )}
+        </Box>
         {assetEventsData && assetEventsData.total_entries > 0 ? (
           <AssetEvents
             data={assetEventsData}

Reply via email to