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 45fa44f0215 UI: Avoid missing task instance links (#71762)
45fa44f0215 is described below

commit 45fa44f02155a2d034d9db126e7e9997e5f0a596
Author: Deepak Jain <[email protected]>
AuthorDate: Tue Aug 18 11:09:17 2026 -0700

    UI: Avoid missing task instance links (#71762)
---
 .../ui/src/components/Graph/TaskLink.test.tsx      | 19 +++++++
 .../airflow/ui/src/components/Graph/TaskLink.tsx   | 66 ++++++++++++----------
 .../ui/src/components/Graph/TaskNode.test.tsx      | 44 ++++++++++++++-
 .../airflow/ui/src/components/Graph/TaskNode.tsx   |  4 ++
 .../ui/src/layouts/Details/Grid/GridTI.test.tsx    | 36 +++++++++---
 .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 19 ++++---
 6 files changed, 140 insertions(+), 48 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.test.tsx 
b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.test.tsx
index 0332c129dfb..d8ff51c3a27 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.test.tsx
@@ -46,4 +46,23 @@ describe("TaskLink", () => {
       "/dags/test_dag/runs/test_run/tasks/one_try?log_level=error",
     );
   });
+
+  it("links to the task overview when the run has no task instance", () => {
+    render(
+      <MemoryRouter 
initialEntries={["/dags/test_dag/runs/test_run/tasks/other_task"]}>
+        <Routes>
+          <Route
+            element={<TaskLink hasTaskInstance={false} id="missing_task" 
label="missing_task" />}
+            path="/dags/:dagId/runs/:runId/tasks/:taskId"
+          />
+        </Routes>
+      </MemoryRouter>,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(screen.getByRole("link", { name: "missing_task" })).toHaveAttribute(
+      "href",
+      "/dags/test_dag/tasks/missing_task",
+    );
+  });
 });
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 5ec36b34c24..90bc9011394 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
@@ -25,44 +25,50 @@ import { taskNodeSeparator } from "src/utils/assetGraph";
 
 type Props = {
   readonly dagId?: string;
+  readonly hasTaskInstance?: boolean;
   readonly id: string;
 } & TaskNameProps;
 
-export const TaskLink = forwardRef<HTMLAnchorElement, Props>(({ id, isGroup, 
isMapped, ...rest }, ref) => {
-  const { dagId: urlDagId = "", groupId, runId, taskId: urlTaskId } = 
useParams();
-  const [searchParams] = useSearchParams();
+export const TaskLink = forwardRef<HTMLAnchorElement, Props>(
+  ({ hasTaskInstance = true, id, isGroup, isMapped, ...rest }, ref) => {
+    const { dagId: urlDagId = "", groupId, runId, taskId: urlTaskId } = 
useParams();
+    const [searchParams] = useSearchParams();
 
-  // Extract dagId and taskId from composite ID
-  const parseCompositeId = (compositeId: string) => {
-    const match = new 
RegExp(`^task:(?<dagId>.*?)${taskNodeSeparator}(?<taskId>.+)$`, 
"u").exec(compositeId);
+    // Extract dagId and taskId from composite ID
+    const parseCompositeId = (compositeId: string) => {
+      const match = new 
RegExp(`^task:(?<dagId>.*?)${taskNodeSeparator}(?<taskId>.+)$`, "u").exec(
+        compositeId,
+      );
 
-    if (match) {
-      return { dagId: match[1], taskId: match[2] };
-    }
+      if (match) {
+        return { dagId: match[1], taskId: match[2] };
+      }
 
-    return { dagId: undefined, taskId: undefined };
-  };
+      return { dagId: undefined, taskId: undefined };
+    };
 
-  const { dagId: extractedDagId, taskId: extractedTaskId } = 
parseCompositeId(id);
-  const dagId = extractedDagId ?? urlDagId;
-  const taskId = extractedTaskId ?? id;
+    const { dagId: extractedDagId, taskId: extractedTaskId } = 
parseCompositeId(id);
+    const dagId = extractedDagId ?? urlDagId;
+    const taskId = extractedTaskId ?? id;
 
-  const basePath = `/dags/${dagId}${runId === undefined ? "" : 
`/runs/${runId}`}`;
-  const taskPath = isGroup
-    ? groupId === taskId
-      ? ""
-      : `/tasks/group/${taskId}`
-    : urlTaskId === taskId
-      ? ""
-      : `/tasks/${taskId}${isMapped && urlTaskId !== taskId && runId !== 
undefined ? "/mapped" : ""}`;
+    const includeRun = runId !== undefined && hasTaskInstance;
+    const basePath = `/dags/${dagId}${includeRun ? `/runs/${runId}` : ""}`;
+    const taskPath = isGroup
+      ? includeRun && groupId === taskId
+        ? ""
+        : `/tasks/group/${taskId}`
+      : includeRun && urlTaskId === taskId
+        ? ""
+        : `/tasks/${taskId}${isMapped && urlTaskId !== taskId && includeRun ? 
"/mapped" : ""}`;
 
-  const targetSearchParams = new URLSearchParams(searchParams);
+    const targetSearchParams = new URLSearchParams(searchParams);
 
-  targetSearchParams.delete(SearchParamsKeys.TRY_NUMBER);
+    targetSearchParams.delete(SearchParamsKeys.TRY_NUMBER);
 
-  return (
-    <RouterLink ref={ref} to={{ pathname: basePath + taskPath, search: 
targetSearchParams.toString() }}>
-      <TaskName isGroup={isGroup} isMapped={isMapped} {...rest} />
-    </RouterLink>
-  );
-});
+    return (
+      <RouterLink ref={ref} to={{ pathname: basePath + taskPath, search: 
targetSearchParams.toString() }}>
+        <TaskName isGroup={isGroup} isMapped={isMapped} {...rest} />
+      </RouterLink>
+    );
+  },
+);
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx 
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx
index 821d0ac2fbe..0bca7601fcd 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx
@@ -16,12 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
 import { ReactFlowProvider } from "@xyflow/react";
 import type { ComponentProps, ReactNode } from "react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
 import { describe, expect, it, vi } from "vitest";
 
-import { Wrapper } from "src/utils/Wrapper";
+import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { BaseWrapper, Wrapper } from "src/utils/Wrapper";
 
 import { TaskNode } from "./TaskNode";
 import { readableTextForFill } from "./nodeColors";
@@ -85,6 +87,44 @@ describe("TaskNode operator colors", () => {
   });
 });
 
+describe("TaskNode links", () => {
+  it("links to the task overview when the run has no task instance", () => {
+    render(
+      <ReactFlowProvider>
+        <TaskNode
+          {...({
+            data: {
+              height: 80,
+              id: "missing_task",
+              label: "missing_task",
+              taskInstance: { dag_version_number: null } as 
LightGridTaskInstanceSummary,
+              type: "task",
+              width: 200,
+            },
+            id: "missing_task",
+          } as unknown as ComponentProps<typeof TaskNode>)}
+        />
+      </ReactFlowProvider>,
+      {
+        wrapper: ({ children }: { readonly children: ReactNode }) => (
+          <BaseWrapper>
+            <MemoryRouter initialEntries={["/dags/test_dag/runs/test_run"]}>
+              <Routes>
+                <Route element={children} path="/dags/:dagId/runs/:runId" />
+              </Routes>
+            </MemoryRouter>
+          </BaseWrapper>
+        ),
+      },
+    );
+
+    expect(screen.getByRole("link", { name: "missing_task" })).toHaveAttribute(
+      "href",
+      "/dags/test_dag/tasks/missing_task",
+    );
+  });
+});
+
 describe("readableTextForFill", () => {
   it.each([
     { color: "#ffffff", expected: "black" },
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx 
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
index e35a4c2a47b..647d5afa84f 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
@@ -95,6 +95,9 @@ export const TaskNode = ({
   const thisChildCount = Object.entries(taskInstance?.child_states ?? {})
     .map(([_state, count]) => count)
     .reduce((sum, val) => sum + val, 0);
+  const hasTaskInstance = isGroup
+    ? true
+    : taskInstance?.dag_version_number !== null && 
taskInstance?.dag_version_number !== undefined;
 
   // Custom colors can mess up the readability of the text, so we calculate a 
readable foreground color for the node based on the background color.
   // Pass the resolved color so Chakra tokens are measured by their hex rather 
than skipped.
@@ -146,6 +149,7 @@ export const TaskNode = ({
               <LinkOverlay asChild>
                 <TaskLink
                   childCount={thisChildCount}
+                  hasTaskInstance={hasTaskInstance}
                   id={id}
                   isGroup={isGroup}
                   isMapped={isMapped}
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
index 31ddb7e98f3..756f844da4d 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
@@ -36,6 +36,7 @@ vi.mock("src/context/colorMode", () => ({
 
 const taskInstance: LightGridTaskInstanceSummary = {
   child_states: null,
+  dag_version_number: 1,
   max_end_date: null,
   min_start_date: null,
   state: "success",
@@ -45,7 +46,16 @@ const taskInstance: LightGridTaskInstanceSummary = {
 
 const SELECTED_RUN_ID = "manual__2026-04-21T00:00:00+00:00";
 
-const renderGridTI = (route: string, taskId = "selected_task", runId = 
SELECTED_RUN_ID) =>
+type RenderGridTIOptions = {
+  readonly instance?: LightGridTaskInstanceSummary;
+  readonly runId?: string;
+  readonly taskId?: string;
+};
+
+const renderGridTI = (
+  route: string,
+  { instance = taskInstance, runId = SELECTED_RUN_ID, taskId = "selected_task" 
}: RenderGridTIOptions = {},
+) =>
   render(
     <BaseWrapper>
       <TimezoneProvider>
@@ -55,7 +65,7 @@ const renderGridTI = (route: string, taskId = 
"selected_task", runId = SELECTED_
               element={
                 <GridTI
                   dagId="example_dag"
-                  instance={{ ...taskInstance, task_id: taskId }}
+                  instance={{ ...instance, task_id: taskId }}
                   label={taskId}
                   runId={runId}
                   taskId={taskId}
@@ -89,7 +99,9 @@ describe("GridTI", () => {
   });
 
   it("does not mark another task square as selected", () => {
-    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, 
"other_task");
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+      taskId: "other_task",
+    });
 
     
expect(screen.getByTestId("task-state-badge")).not.toHaveAttribute("data-selected");
     
expect(screen.getByTestId("task-state-badge").closest("[data-task-id='other_task']")).toHaveAttribute(
@@ -99,11 +111,7 @@ describe("GridTI", () => {
   });
 
   it("keeps the task row selected without marking the same task square in 
another Dag run as selected", () => {
-    renderGridTI(
-      `/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`,
-      "selected_task",
-      "other_run",
-    );
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, { 
runId: "other_run" });
 
     
expect(screen.getByTestId("task-state-badge")).not.toHaveAttribute("data-selected");
     
expect(screen.getByTestId("task-state-badge").closest("[data-task-id='selected_task']")).toHaveAttribute(
@@ -111,4 +119,16 @@ describe("GridTI", () => {
       "true",
     );
   });
+
+  it("links to the task overview when the run has no task instance", () => {
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+      instance: { ...taskInstance, dag_version_number: null },
+      taskId: "missing_task",
+    });
+
+    
expect(screen.getByTestId(`grid-${SELECTED_RUN_ID}-missing_task`)).toHaveAttribute(
+      "href",
+      "/dags/example_dag/tasks/missing_task",
+    );
+  });
 });
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 4f575b8ee0e..9bd0bade260 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
@@ -55,14 +55,17 @@ export const GridTI = ({
 
   const [searchParams] = useSearchParams();
 
-  const taskUrl = buildTaskInstanceUrl({
-    currentPathname: location.pathname,
-    dagId,
-    isGroup,
-    isMapped: Boolean(isMapped),
-    runId,
-    taskId,
-  });
+  const hasTaskInstance = instance.dag_version_number !== null && 
instance.dag_version_number !== undefined;
+  const taskUrl = hasTaskInstance
+    ? buildTaskInstanceUrl({
+        currentPathname: location.pathname,
+        dagId,
+        isGroup,
+        isMapped: Boolean(isMapped),
+        runId,
+        taskId,
+      })
+    : `/dags/${dagId}/tasks/${isGroup ? "group/" : ""}${taskId}`;
 
   // Remove try_number query param when navigating to reset to the
   // latest try of the task instance and avoid issues with invalid try numbers:

Reply via email to