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 c7afcfc568b Add task group detail for dag run selected case (#50309)
c7afcfc568b is described below
commit c7afcfc568b5f1bfc5c5be5b780ddc0f8320f976
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Thu May 8 23:49:04 2025 +0800
Add task group detail for dag run selected case (#50309)
* Add group detail for dag run selected
* Apply suggestions from code review
Co-authored-by: Brent Bovenzi <[email protected]>
* Fix TaskLink structure
* Fix crosshair selection
* Update GridTI bg
---------
Co-authored-by: Brent Bovenzi <[email protected]>
---
.../airflow/ui/src/components/Graph/TaskLink.tsx | 14 ++--
.../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 50 +++++++-----
.../ui/src/layouts/Details/Grid/TaskNames.tsx | 4 +-
.../pages/GroupTaskInstance/GroupTaskInstance.tsx | 89 ++++++++++++++++++++++
.../ui/src/pages/GroupTaskInstance/Header.tsx | 65 ++++++++++++++++
.../ui/src/pages/GroupTaskInstance/index.ts | 20 +++++
.../ui/src/pages/TaskInstances/TaskInstances.tsx | 8 +-
airflow-core/src/airflow/ui/src/router.tsx | 6 ++
8 files changed, 223 insertions(+), 33 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 278c6af3092..4a86c5eb9a5 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
@@ -29,21 +29,23 @@ export const TaskLink = forwardRef<HTMLAnchorElement,
Props>(({ id, isGroup, isM
const { dagId = "", runId, taskId } = useParams();
const [searchParams] = useSearchParams();
- // We don't have a task group details page to link to
- if (isGroup) {
- return <TaskName isGroup={true} isMapped={isMapped} {...rest} />;
+ 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" : ""}`;
+
return (
<RouterLink
ref={ref}
to={{
- // Do not include runId if there is no selected run, clicking a second
time will deselect a task id
- pathname: `/dags/${dagId}/${runId === undefined ? "" :
`runs/${runId}/`}${taskId === id ? "" : `tasks/${id}`}${isMapped && taskId !==
id && runId !== undefined ? "/mapped" : ""}`,
+ pathname,
search: searchParams.toString(),
}}
>
- <TaskName isMapped={isMapped} {...rest} />
+ <TaskName isGroup={isGroup} isMapped={isMapped} {...rest} />
</RouterLink>
);
});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index 08a17ea9e75..cd737af673a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -52,12 +52,12 @@ const onMouseLeave = (event: MouseEvent<HTMLDivElement>) =>
{
};
const Instance = ({ dagId, isGroup, isMapped, runId, search, state, taskId }:
Props) => {
- const { taskId: selectedTaskId } = useParams();
+ const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
return (
<Flex
alignItems="center"
- bg={selectedTaskId === taskId ? "blue.muted" : undefined}
+ bg={selectedTaskId === taskId || selectedGroupId === taskId ?
"blue.muted" : undefined}
height="20px"
id={taskId.replaceAll(".", "-")}
justifyContent="center"
@@ -70,26 +70,34 @@ const Instance = ({ dagId, isGroup, isMapped, runId,
search, state, taskId }: Pr
zIndex={1}
>
{isGroup ? (
- <Badge
- borderRadius={4}
- colorPalette={state === null ? "none" : state}
- height="14px"
- minH={0}
- opacity={state === "success" ? 0.6 : 1}
- p={0}
- variant="solid"
- width="14px"
+ <Link
+ replace
+ to={{
+ pathname: `/dags/${dagId}/runs/${runId}/tasks/group/${taskId}`,
+ search,
+ }}
>
- {state === undefined ? undefined : (
- <StateIcon
- size={10}
- state={state}
- style={{
- marginLeft: "2px",
- }}
- />
- )}
- </Badge>
+ <Badge
+ borderRadius={4}
+ colorPalette={state === null ? "none" : state}
+ height="14px"
+ minH={0}
+ opacity={state === "success" ? 0.6 : 1}
+ p={0}
+ variant="solid"
+ width="14px"
+ >
+ {state === undefined ? undefined : (
+ <StateIcon
+ size={10}
+ state={state}
+ style={{
+ marginLeft: "2px",
+ }}
+ />
+ )}
+ </Badge>
+ </Link>
) : (
<Link
replace
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 60105579223..2061066a935 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
@@ -49,12 +49,12 @@ const onMouseLeave = (event: MouseEvent<HTMLDivElement>) =>
{
export const TaskNames = ({ nodes }: Props) => {
const { toggleGroupId } = useOpenGroups();
- const { dagId = "", taskId } = useParams();
+ const { dagId = "", groupId, taskId } = useParams();
const [searchParams] = useSearchParams();
return nodes.map((node) => (
<Box
- bg={node.id === taskId ? "blue.muted" : undefined}
+ bg={node.id === taskId || node.id === groupId ? "blue.muted" : undefined}
borderBottomWidth={1}
borderColor={node.isGroup ? "border.emphasized" : "border.muted"}
id={node.id.replaceAll(".", "-")}
diff --git
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
new file mode 100644
index 00000000000..32882e3e882
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
@@ -0,0 +1,89 @@
+/*!
+ * 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 { ReactFlowProvider } from "@xyflow/react";
+import { MdOutlineTask } from "react-icons/md";
+import { useParams } from "react-router-dom";
+
+import {
+ useDagRunServiceGetDagRun,
+ useDagServiceGetDagDetails,
+ useGridServiceGridData,
+} from "openapi/queries";
+import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
+import { isStatePending, useAutoRefresh } from "src/utils";
+
+import { Header } from "./Header";
+
+export const GroupTaskInstance = () => {
+ const { dagId = "", groupId = "", runId = "" } = useParams();
+ const refetchInterval = useAutoRefresh({ dagId });
+
+ const {
+ data: dag,
+ error: dagError,
+ isLoading: isDagLoading,
+ } = useDagServiceGetDagDetails({
+ dagId,
+ });
+
+ const { data: dagRun } = useDagRunServiceGetDagRun(
+ {
+ dagId,
+ dagRunId: runId,
+ },
+ undefined,
+ { enabled: runId !== "" },
+ );
+
+ // Filter grid data to get only a single dag run
+ const { data, error, isLoading } = useGridServiceGridData(
+ {
+ dagId,
+ limit: 1,
+ offset: 0,
+ runAfterGte: dagRun?.run_after,
+ runAfterLte: dagRun?.run_after,
+ },
+ undefined,
+ {
+ enabled: dagRun !== undefined,
+ refetchInterval: (query) =>
+ query.state.data?.dag_runs.some((dr) => isStatePending(dr.state)) &&
refetchInterval,
+ },
+ );
+
+ const taskInstance = data?.dag_runs
+ .find((dr) => dr.dag_run_id === runId)
+ ?.task_instances.find((ti) => ti.task_id === groupId);
+
+ const tabs = [{ icon: <MdOutlineTask />, label: "Task Instances", value: ""
}];
+
+ return (
+ <ReactFlowProvider>
+ <DetailsLayout dag={dag} error={error ?? dagError} isLoading={isLoading
|| isDagLoading} tabs={tabs}>
+ {taskInstance === undefined ? undefined : (
+ <Header
+ isRefreshing={Boolean(isStatePending(taskInstance.state) &&
Boolean(refetchInterval))}
+ taskInstance={taskInstance}
+ />
+ )}
+ </DetailsLayout>
+ </ReactFlowProvider>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
new file mode 100644
index 00000000000..aa0632b4493
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
@@ -0,0 +1,65 @@
+/*!
+ * 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 } from "@chakra-ui/react";
+import type { ReactNode } from "react";
+import { MdOutlineTask } from "react-icons/md";
+
+import type { GridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { HeaderCard } from "src/components/HeaderCard";
+import Time from "src/components/Time";
+import { getDuration } from "src/utils";
+
+export const Header = ({
+ isRefreshing,
+ taskInstance,
+}: {
+ readonly isRefreshing?: boolean;
+ readonly taskInstance: GridTaskInstanceSummary;
+}) => {
+ const entries: Array<{ label: string; value: number | ReactNode | string }>
= [];
+
+ if (taskInstance.child_states !== null) {
+ Object.entries(taskInstance.child_states).forEach(([state, count]) => {
+ if (count > 0) {
+ entries.push({ label: `Total ${state}`, value: count });
+ }
+ });
+ }
+ const stats = [
+ ...entries,
+ { label: "Earliest Start", value: <Time datetime={taskInstance.start_date}
/> },
+ { label: "Latest End", value: <Time datetime={taskInstance.end_date} /> },
+ ...(Boolean(taskInstance.start_date)
+ ? [{ label: "Total Duration", value:
getDuration(taskInstance.start_date, taskInstance.end_date) }]
+ : []),
+ ];
+
+ return (
+ <Box>
+ <HeaderCard
+ icon={<MdOutlineTask />}
+ isRefreshing={isRefreshing}
+ state={taskInstance.state}
+ stats={stats}
+ subTitle={<Time datetime={taskInstance.start_date} />}
+ title={taskInstance.task_id}
+ />
+ </Box>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
new file mode 100644
index 00000000000..f1f08181938
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * 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.
+ */
+
+export { GroupTaskInstance } from "./GroupTaskInstance";
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index a25e053dd2c..cf6e2987524 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -168,7 +168,7 @@ const taskInstanceColumns = (
];
export const TaskInstances = () => {
- const { dagId, runId, taskId } = useParams();
+ const { dagId, groupId, runId, taskId } = useParams();
const [searchParams] = useSearchParams();
const { setTableURLState, tableURLState } = useTableURLState();
const { pagination, sorting } = tableURLState;
@@ -196,8 +196,8 @@ export const TaskInstances = () => {
orderBy,
startDateGte: startDate ?? undefined,
state: hasFilteredState ? filteredState : undefined,
- taskDisplayNamePattern: Boolean(taskDisplayNamePattern) ?
taskDisplayNamePattern : undefined,
- taskId: taskId ?? undefined,
+ taskDisplayNamePattern: groupId ?? taskDisplayNamePattern ?? undefined,
+ taskId: Boolean(groupId) ? undefined : taskId,
},
undefined,
{
@@ -214,7 +214,7 @@ export const TaskInstances = () => {
taskDisplayNamePattern={taskDisplayNamePattern}
/>
<DataTable
- columns={taskInstanceColumns(dagId, runId, taskId)}
+ columns={taskInstanceColumns(dagId, runId, Boolean(groupId) ?
undefined : taskId)}
data={data?.task_instances ?? []}
errorMessage={<ErrorAlert error={error} />}
initialState={tableURLState}
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index 77d55de10bf..39d7beed9a2 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -53,6 +53,7 @@ import { Variables } from "src/pages/Variables";
import { XCom } from "src/pages/XCom";
import { Configs } from "./pages/Configs";
+import { GroupTaskInstance } from "./pages/GroupTaskInstance";
import { Security } from "./pages/Security";
import { client } from "./queryClient";
@@ -170,6 +171,11 @@ export const routerConfig = [
element: <MappedTaskInstance />,
path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped",
},
+ {
+ children: [{ element: <TaskInstances />, index: true }],
+ element: <GroupTaskInstance />,
+ path: "dags/:dagId/runs/:runId/tasks/group/:groupId",
+ },
{
children: taskInstanceRoutes,
element: <TaskInstance />,