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}