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 b9bf4880d9b Add task group detail across dag runs (#50412)
b9bf4880d9b is described below
commit b9bf4880d9bc0f015d0114f595829a1767048160
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Fri May 16 01:36:45 2025 +0800
Add task group detail across dag runs (#50412)
---
.../airflow/ui/src/components/Graph/TaskLink.tsx | 25 ++++------
.../src/airflow/ui/src/components/TaskName.tsx | 1 -
.../ui/src/layouts/Details/DagBreadcrumb.tsx | 19 +++++++-
.../ui/src/layouts/Details/Grid/TaskNames.tsx | 31 +++++++-----
.../airflow/ui/src/pages/Task/GroupTaskHeader.tsx | 26 ++++++++++
.../ui/src/pages/Task/Overview/Overview.tsx | 8 ++--
.../src/airflow/ui/src/pages/Task/Task.tsx | 37 +++++++++++++--
airflow-core/src/airflow/ui/src/router.tsx | 8 ++++
airflow-core/src/airflow/ui/src/utils/groupTask.ts | 55 ++++++++++++++++++++++
9 files changed, 175 insertions(+), 35 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
index 4a86c5eb9a5..203bfb652ee 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
@@ -26,25 +26,20 @@ type Props = {
} & TaskNameProps;
export const TaskLink = forwardRef<HTMLAnchorElement, Props>(({ id, isGroup,
isMapped, ...rest }, ref) => {
- const { dagId = "", runId, taskId } = useParams();
+ const { dagId = "", groupId, runId, taskId } = useParams();
const [searchParams] = useSearchParams();
- if (isGroup && runId === undefined) {
- return undefined;
- }
-
- const pathname = isGroup
- ? `/dags/${dagId}/runs/${runId}/tasks/group/${id}`
- : `/dags/${dagId}/${runId === undefined ? "" : `runs/${runId}/`}${taskId
=== id ? "" : `tasks/${id}`}${isMapped && taskId !== id && runId !== undefined
? "/mapped" : ""}`;
+ const basePath = `/dags/${dagId}${runId === undefined ? "" :
`/runs/${runId}`}`;
+ const taskPath = isGroup
+ ? groupId === id
+ ? ""
+ : `/tasks/group/${id}`
+ : taskId === id
+ ? ""
+ : `/tasks/${id}${isMapped && taskId !== id && runId !== undefined ?
"/mapped" : ""}`;
return (
- <RouterLink
- ref={ref}
- to={{
- pathname,
- search: searchParams.toString(),
- }}
- >
+ <RouterLink ref={ref} to={{ pathname: basePath + taskPath, search:
searchParams.toString() }}>
<TaskName isGroup={isGroup} isMapped={isMapped} {...rest} />
</RouterLink>
);
diff --git a/airflow-core/src/airflow/ui/src/components/TaskName.tsx
b/airflow-core/src/airflow/ui/src/components/TaskName.tsx
index b2c1ebcea2b..9349cb27a51 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskName.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskName.tsx
@@ -48,7 +48,6 @@ export const TaskName = ({
setupTeardownType,
...rest
}: TaskNameProps) => {
- // We don't have a task group details page to link to
if (isGroup) {
return (
<Text
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
index 7afa19f9a6f..6e175b9b28e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -32,7 +32,7 @@ import { TogglePause } from "src/components/TogglePause";
import { isStatePending, useAutoRefresh } from "src/utils";
export const DagBreadcrumb = () => {
- const { dagId = "", mapIndex = "-1", runId, taskId } = useParams();
+ const { dagId = "", groupId, mapIndex = "-1", runId, taskId } = useParams();
const refetchInterval = useAutoRefresh({ dagId });
const { data: dag } = useDagServiceGetDagDetails({
@@ -86,6 +86,23 @@ export const DagBreadcrumb = () => {
});
}
+ // Add group breadcrumb
+ if (groupId !== undefined) {
+ if (runId === undefined) {
+ links.push({
+ label: "All Runs",
+ title: "Dag Run",
+ value: `/dags/${dagId}/runs`,
+ });
+ }
+
+ links.push({
+ label: groupId,
+ title: "Group",
+ value: `/dags/${dagId}/groups/${groupId}`,
+ });
+ }
+
// Add task breadcrumb
if (runId !== undefined && taskId !== undefined) {
if (task?.is_mapped) {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
index 2061066a935..9792b1f4c54 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
@@ -65,17 +65,26 @@ export const TaskNames = ({ nodes }: Props) => {
transition="background-color 0.2s"
>
{node.isGroup ? (
- <Flex>
- <TaskName
- display="inline"
- fontSize="sm"
- fontWeight="normal"
- isGroup={true}
- isMapped={Boolean(node.is_mapped)}
- label={node.label}
- paddingLeft={node.depth * 3 + 2}
- setupTeardownType={node.setup_teardown_type}
- />
+ <Flex alignItems="center">
+ <Link data-testid={node.id} display="inline">
+ <RouterLink
+ replace
+ to={{
+ pathname: `/dags/${dagId}/tasks/group/${node.id}`,
+ search: searchParams.toString(),
+ }}
+ >
+ <TaskName
+ fontSize="sm"
+ fontWeight="normal"
+ isGroup={true}
+ isMapped={Boolean(node.is_mapped)}
+ label={node.label}
+ paddingLeft={node.depth * 3 + 2}
+ setupTeardownType={node.setup_teardown_type}
+ />
+ </RouterLink>
+ </Link>
<chakra.button
aria-label="Toggle group"
display="inline"
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx
b/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx
new file mode 100644
index 00000000000..fc0a2f29233
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx
@@ -0,0 +1,26 @@
+/*!
+ * 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 { AiOutlineGroup } from "react-icons/ai";
+
+import type { NodeResponse } from "openapi/requests/types.gen";
+import { HeaderCard } from "src/components/HeaderCard";
+
+export const GroupTaskHeader = ({ groupTask }: { readonly groupTask:
NodeResponse }) => (
+ <HeaderCard icon={<AiOutlineGroup />} stats={[]} title={groupTask.label} />
+);
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
b/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
index 24ef0fb2067..e36a613eb61 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
@@ -30,7 +30,7 @@ import { isStatePending, useAutoRefresh } from "src/utils";
const defaultHour = "24";
export const Overview = () => {
- const { dagId = "", taskId } = useParams();
+ const { dagId = "", groupId, taskId } = useParams();
const now = dayjs();
const [startDate, setStartDate] = useState(now.subtract(Number(defaultHour),
"hour").toISOString());
@@ -46,7 +46,8 @@ export const Overview = () => {
runAfterGte: startDate,
runAfterLte: endDate,
state: ["failed"],
- taskId,
+ taskDisplayNamePattern: groupId ?? undefined,
+ taskId: Boolean(groupId) ? undefined : taskId,
});
const { data: taskInstances, isLoading: isLoadingTaskInstances } =
useTaskInstanceServiceGetTaskInstances(
@@ -55,7 +56,8 @@ export const Overview = () => {
dagRunId: "~",
limit: 14,
orderBy: "-run_after",
- taskId,
+ taskDisplayNamePattern: groupId ?? undefined,
+ taskId: Boolean(groupId) ? undefined : taskId,
},
undefined,
{
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
index 44da8f8368b..dd20f01a03f 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
@@ -21,9 +21,11 @@ import { LuChartColumn } from "react-icons/lu";
import { MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
import { useParams } from "react-router-dom";
-import { useDagServiceGetDagDetails, useTaskServiceGetTask } from
"openapi/queries";
+import { useDagServiceGetDagDetails, useGridServiceGridData,
useTaskServiceGetTask } from "openapi/queries";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
+import { getGroupTask } from "src/utils/groupTask";
+import { GroupTaskHeader } from "./GroupTaskHeader";
import { Header } from "./Header";
const tabs = [
@@ -33,9 +35,30 @@ const tabs = [
];
export const Task = () => {
- const { dagId = "", taskId = "" } = useParams();
+ const { dagId = "", groupId, taskId } = useParams();
- const { data: task, error, isLoading } = useTaskServiceGetTask({ dagId,
taskId });
+ const displayTabs = groupId === undefined ? tabs : tabs.filter((tab) =>
tab.label !== "Events");
+
+ const {
+ data: task,
+ error,
+ isLoading,
+ } = useTaskServiceGetTask({ dagId, taskId: groupId ?? taskId }, undefined, {
+ enabled: groupId === undefined,
+ });
+
+ const { data: gridData } = useGridServiceGridData(
+ {
+ dagId,
+ includeDownstream: true,
+ includeUpstream: true,
+ },
+ undefined,
+ { enabled: groupId !== undefined },
+ );
+
+ const groupTask =
+ groupId === undefined ? undefined : getGroupTask(gridData?.structure.nodes
?? [], groupId);
const {
data: dag,
@@ -47,8 +70,14 @@ export const Task = () => {
return (
<ReactFlowProvider>
- <DetailsLayout dag={dag} error={error ?? dagError} isLoading={isLoading
|| isDagLoading} tabs={tabs}>
+ <DetailsLayout
+ dag={dag}
+ error={error ?? dagError}
+ isLoading={isLoading || isDagLoading}
+ tabs={displayTabs}
+ >
{task === undefined ? undefined : <Header task={task} />}
+ {groupTask ? <GroupTaskHeader groupTask={groupTask} /> : undefined}
</DetailsLayout>
</ReactFlowProvider>
);
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index 39d7beed9a2..3b7edfdb39e 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -176,6 +176,14 @@ export const routerConfig = [
element: <GroupTaskInstance />,
path: "dags/:dagId/runs/:runId/tasks/group/:groupId",
},
+ {
+ children: [
+ { element: <TaskOverview />, index: true },
+ { element: <TaskInstances />, path: "task_instances" },
+ ],
+ element: <Task />,
+ path: "dags/:dagId/tasks/group/:groupId",
+ },
{
children: taskInstanceRoutes,
element: <TaskInstance />,
diff --git a/airflow-core/src/airflow/ui/src/utils/groupTask.ts
b/airflow-core/src/airflow/ui/src/utils/groupTask.ts
new file mode 100644
index 00000000000..8b736dc7c8a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/groupTask.ts
@@ -0,0 +1,55 @@
+/*!
+ * 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 type { NodeResponse } from "openapi/requests/types.gen";
+
+/**
+ * Finds a task node by its ID in a tree of nodes
+ * @param nodes - Array of root nodes to search through
+ * @param targetId - ID of the node to find
+ * @returns The found node or undefined if not found
+ */
+export const getGroupTask = (nodes: Array<NodeResponse>, targetId: string):
NodeResponse | undefined => {
+ if (!nodes.length || !targetId) {
+ return undefined;
+ }
+
+ const queue: Array<NodeResponse> = [...nodes];
+ const [root] = targetId.split(".");
+
+ while (queue.length > 0) {
+ const node = queue.shift();
+
+ if (node) {
+ if (node.id === targetId) {
+ return node;
+ }
+
+ if (node.children && node.children.length > 0) {
+ const nextNode =
+ node.id === root && targetId.includes(".")
+ ? node.children.find((child) => child.id === targetId)
+ : undefined;
+
+ queue.unshift(...(nextNode ? [nextNode] : node.children));
+ }
+ }
+ }
+
+ return undefined;
+};