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: