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 35d76bc147b UI: Implement automatic link target detection for 
extra_links (#64404)
35d76bc147b is described below

commit 35d76bc147b2f74bb94f9229cc51e518ff726952
Author: Subham <[email protected]>
AuthorDate: Thu Apr 9 21:40:52 2026 +0530

    UI: Implement automatic link target detection for extra_links (#64404)
---
 .../ui/src/pages/TaskInstance/ExtraLinks.test.tsx  | 124 +++++++++++++++++++++
 .../ui/src/pages/TaskInstance/ExtraLinks.tsx       |  26 ++++-
 2 files changed, 145 insertions(+), 5 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.test.tsx
new file mode 100644
index 00000000000..75c65e8abcc
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.test.tsx
@@ -0,0 +1,124 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { useParams } from "react-router-dom";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import * as queries from "openapi/queries";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { ExtraLinks } from "./ExtraLinks";
+
+vi.mock("openapi/queries");
+vi.mock("react-router-dom", async () => {
+  const actual = await vi.importActual("react-router-dom");
+
+  return {
+    ...actual,
+    useParams: vi.fn(),
+  };
+});
+
+describe("ExtraLinks Component", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(useParams).mockReturnValue({
+      dagId: "test-dag",
+      mapIndex: "-1",
+      runId: "test-run",
+      taskId: "test-task",
+    });
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("renders internal links with target='_self'", () => {
+    vi.mocked(queries.useTaskInstanceServiceGetExtraLinks).mockReturnValue({
+      data: {
+        extra_links: {
+          "Internal Link": "/dags/test/runs/run1",
+          "Same Origin": "http://localhost:3000/some-path";,
+        },
+      },
+    } as unknown as ReturnType<typeof 
queries.useTaskInstanceServiceGetExtraLinks>);
+
+    // Mock window.location.origin
+    vi.stubGlobal("location", { origin: "http://localhost:3000"; });
+
+    render(
+      <Wrapper>
+        <ExtraLinks refetchInterval={false} />
+      </Wrapper>,
+    );
+
+    const internalLink = screen.getByText("Internal Link");
+
+    expect(internalLink.closest("a")).toHaveAttribute("target", "_self");
+
+    const sameOriginLink = screen.getByText("Same Origin");
+
+    expect(sameOriginLink.closest("a")).toHaveAttribute("target", "_self");
+  });
+
+  it("renders external links with target='_blank'", () => {
+    vi.mocked(queries.useTaskInstanceServiceGetExtraLinks).mockReturnValue({
+      data: {
+        extra_links: {
+          "External Link": "https://www.google.com";,
+        },
+      },
+    } as unknown as ReturnType<typeof 
queries.useTaskInstanceServiceGetExtraLinks>);
+
+    // Mock window.location.origin
+    vi.stubGlobal("location", { origin: "http://localhost:3000"; });
+
+    render(
+      <Wrapper>
+        <ExtraLinks refetchInterval={false} />
+      </Wrapper>,
+    );
+
+    const externalLink = screen.getByText("External Link");
+
+    expect(externalLink.closest("a")).toHaveAttribute("target", "_blank");
+  });
+
+  it("filters out null urls", () => {
+    vi.mocked(queries.useTaskInstanceServiceGetExtraLinks).mockReturnValue({
+      data: {
+        extra_links: {
+          Invalid: null,
+          Valid: "http://localhost:3000/valid";,
+        },
+      },
+    } as unknown as ReturnType<typeof 
queries.useTaskInstanceServiceGetExtraLinks>);
+
+    render(
+      <Wrapper>
+        <ExtraLinks refetchInterval={false} />
+      </Wrapper>,
+    );
+
+    expect(screen.getByText("Valid")).toBeInTheDocument();
+    expect(screen.queryByText("Invalid")).not.toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
index 2ff7876dfc0..da4ab0342d7 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
@@ -26,6 +26,16 @@ type ExtraLinksProps = {
   readonly refetchInterval: number | false;
 };
 
+const getTarget = (url: string) => {
+  try {
+    return new URL(url, globalThis.location.origin).origin === 
globalThis.location.origin
+      ? "_self"
+      : "_blank";
+  } catch {
+    return "_blank";
+  }
+};
+
 export const ExtraLinks = ({ refetchInterval }: ExtraLinksProps) => {
   const { t: translate } = useTranslation("dag");
   const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
@@ -47,15 +57,21 @@ export const ExtraLinks = ({ refetchInterval }: 
ExtraLinksProps) => {
     <Box py={1}>
       <Heading size="sm">{translate("extraLinks")}</Heading>
       <HStack gap={2} py={2}>
-        {Object.entries(data.extra_links).map(([key, value], _) =>
-          value === null ? undefined : (
+        {Object.entries(data.extra_links).map(([key, url]) => {
+          if (url === null) {
+            return undefined;
+          }
+
+          const target = getTarget(url);
+
+          return (
             <Button asChild colorPalette="brand" key={key} variant="surface">
-              <a href={value} rel="noopener noreferrer" target="_blank">
+              <a href={url} rel="noopener noreferrer" target={target}>
                 {key}
               </a>
             </Button>
-          ),
-        )}
+          );
+        })}
       </HStack>
     </Box>
   ) : undefined;

Reply via email to