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;