This is an automated email from the ASF dual-hosted git repository.

jasonliu 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 ab037cb0a01 Add external logs link (#49475)
ab037cb0a01 is described below

commit ab037cb0a01c2af9be7f3b3e2e3696234e3a3099
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Thu Apr 24 10:05:34 2025 +0800

    Add external logs link (#49475)
    
    * feat: support external log
    
    * test: add test and dataModel
    
    Co-Authored-By: LIU ZHE YOU <[email protected]>
    
    * refactor: test structure
    
    Co-Authored-By: LIU ZHE YOU <[email protected]>
    
    * fix: airflowctl type in static check
    
    * fix: api and ui
    
    Co-authored-by: pierrejeambrun <[email protected]>
    
    * fix: miss rel and coding style
    
    * fix: replace a with `Link`
    
    Co-authored-by: Brent Bovenzi <[email protected]>
    
    ---------
    
    Co-authored-by: LIU ZHE YOU <[email protected]>
    Co-authored-by: pierrejeambrun <[email protected]>
    Co-authored-by: Brent Bovenzi <[email protected]>
---
 .../airflow/api_fastapi/core_api/datamodels/log.py |  6 ++
 .../api_fastapi/core_api/datamodels/ui/config.py   |  2 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  9 +++
 .../core_api/openapi/v1-rest-api-generated.yaml    | 89 ++++++++++++++++++++++
 .../api_fastapi/core_api/routes/public/log.py      | 37 ++++++++-
 .../api_fastapi/core_api/routes/ui/config.py       |  4 +
 .../src/airflow/ui/openapi-gen/queries/common.ts   | 27 +++++++
 .../ui/openapi-gen/queries/ensureQueryData.ts      | 38 +++++++++
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts | 38 +++++++++
 .../src/airflow/ui/openapi-gen/queries/queries.ts  | 42 ++++++++++
 .../src/airflow/ui/openapi-gen/queries/suspense.ts | 42 ++++++++++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 29 +++++++
 .../ui/openapi-gen/requests/services.gen.ts        | 37 +++++++++
 .../airflow/ui/openapi-gen/requests/types.gen.ts   | 50 ++++++++++++
 .../ui/src/pages/TaskInstance/ExtraLinks.tsx       |  2 +-
 .../pages/TaskInstance/Logs/ExternalLogLink.tsx    | 71 +++++++++++++++++
 .../ui/src/pages/TaskInstance/Logs/Logs.tsx        | 15 ++++
 .../api_fastapi/core_api/routes/public/test_log.py | 52 +++++++++++++
 .../api_fastapi/core_api/routes/ui/test_config.py  |  2 +
 .../src/airflowctl/api/datamodels/generated.py     |  8 ++
 20 files changed, 598 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
index e67264ae3c3..aa1298d1745 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/log.py
@@ -42,3 +42,9 @@ class TaskInstancesLogResponse(BaseModel):
     content: list[StructuredLogMessage] | list[str]
     """Either a list of parsed events, or a list of lines on parse error"""
     continuation_token: str | None
+
+
+class ExternalLogUrlResponse(BaseModel):
+    """Response for the external log URL endpoint."""
+
+    url: str
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
index e7c39b3f3a1..cb525918493 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
@@ -41,3 +41,5 @@ class ConfigResponse(BaseModel):
     audit_view_included_events: str
     test_connection: str
     dashboard_alert: list[UIAlert]
+    show_external_log_redirect: bool
+    external_log_name: str | None = None
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 9c586c694b1..1d3bc9a5d06 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -764,6 +764,14 @@ components:
             $ref: '#/components/schemas/UIAlert'
           type: array
           title: Dashboard Alert
+        show_external_log_redirect:
+          type: boolean
+          title: Show External Log Redirect
+        external_log_name:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: External Log Name
       type: object
       required:
       - navbar_color
@@ -783,6 +791,7 @@ components:
       - audit_view_included_events
       - test_connection
       - dashboard_alert
+      - show_external_log_redirect
       title: ConfigResponse
       description: configuration serializer.
     ConnectionHookFieldBehavior:
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
index c590ea0080b..79148509198 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
@@ -6417,6 +6417,85 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  
/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}:
+    get:
+      tags:
+      - Task Instance
+      summary: Get External Log Url
+      description: Get external log URL for a specific task instance.
+      operationId: get_external_log_url
+      security:
+      - OAuth2PasswordBearer: []
+      parameters:
+      - name: dag_id
+        in: path
+        required: true
+        schema:
+          type: string
+          title: Dag Id
+      - name: dag_run_id
+        in: path
+        required: true
+        schema:
+          type: string
+          title: Dag Run Id
+      - name: task_id
+        in: path
+        required: true
+        schema:
+          type: string
+          title: Task Id
+      - name: try_number
+        in: path
+        required: true
+        schema:
+          type: integer
+          exclusiveMinimum: 0
+          title: Try Number
+      - name: map_index
+        in: query
+        required: false
+        schema:
+          type: integer
+          default: -1
+          title: Map Index
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ExternalLogUrlResponse'
+        '401':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Unauthorized
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Forbidden
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Bad Request
+        '404':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Not Found
+        '422':
+          description: Validation Error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPValidationError'
   /api/v2/parseDagFile/{file_token}:
     put:
       tags:
@@ -8801,6 +8880,16 @@ components:
       - extra
       title: EventLogResponse
       description: Event Log Response.
+    ExternalLogUrlResponse:
+      properties:
+        url:
+          type: string
+          title: Url
+      type: object
+      required:
+      - url
+      title: ExternalLogUrlResponse
+      description: Response for the external log URL endpoint.
     ExtraLinkCollectionResponse:
       properties:
         extra_links:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
index 3483453d829..e95fcfe2300 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/log.py
@@ -30,7 +30,7 @@ from airflow.api_fastapi.common.db.common import SessionDep
 from airflow.api_fastapi.common.headers import HeaderAcceptJsonOrText
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.common.types import Mimetype
-from airflow.api_fastapi.core_api.datamodels.log import 
TaskInstancesLogResponse
+from airflow.api_fastapi.core_api.datamodels.log import 
ExternalLogUrlResponse, TaskInstancesLogResponse
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import DagAccessEntity, 
requires_access_dag
 from airflow.exceptions import TaskNotFound
@@ -151,3 +151,38 @@ def get_log(
             "Airflow-Continuation-Token": 
URLSafeSerializer(request.app.state.secret_key).dumps(metadata)
         }
     return Response(media_type="application/x-ndjson", content=logs, 
headers=headers)
+
+
+@task_instances_log_router.get(
+    "/{task_id}/externalLogUrl/{try_number}",
+    responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, 
status.HTTP_404_NOT_FOUND]),
+    dependencies=[Depends(requires_access_dag("GET", 
DagAccessEntity.TASK_INSTANCE))],
+)
+def get_external_log_url(
+    dag_id: str,
+    dag_run_id: str,
+    task_id: str,
+    try_number: PositiveInt,
+    session: SessionDep,
+    map_index: int = -1,
+) -> ExternalLogUrlResponse:
+    """Get external log URL for a specific task instance."""
+    task_log_reader = TaskLogReader()
+
+    if not task_log_reader.supports_external_link:
+        raise HTTPException(status.HTTP_400_BAD_REQUEST, "Task log handler 
does not support external logs.")
+
+    # Fetch the task instance
+    query = select(TaskInstance).where(
+        TaskInstance.task_id == task_id,
+        TaskInstance.dag_id == dag_id,
+        TaskInstance.run_id == dag_run_id,
+        TaskInstance.map_index == map_index,
+    )
+    ti = session.scalar(query)
+
+    if ti is None:
+        raise HTTPException(status.HTTP_404_NOT_FOUND, "TaskInstance not 
found")
+
+    url = task_log_reader.log_handler.get_external_log_url(ti, try_number)
+    return ExternalLogUrlResponse(url=url)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
index 11eefab1c85..2b797e64873 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/config.py
@@ -26,6 +26,7 @@ from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_
 from airflow.api_fastapi.core_api.security import requires_access_configuration
 from airflow.configuration import conf
 from airflow.settings import DASHBOARD_UIALERTS
+from airflow.utils.log.log_reader import TaskLogReader
 
 config_router = AirflowRouter(tags=["Config"])
 
@@ -56,12 +57,15 @@ def get_configs() -> ConfigResponse:
 
     config = {key: conf_dict["webserver"].get(key) for key in 
WEBSERVER_CONFIG_KEYS}
 
+    task_log_reader = TaskLogReader()
     additional_config: dict[str, Any] = {
         "instance_name": conf.get("webserver", "instance_name", 
fallback="Airflow"),
         "audit_view_included_events": conf.get("webserver", 
"audit_view_included_events", fallback=""),
         "audit_view_excluded_events": conf.get("webserver", 
"audit_view_excluded_events", fallback=""),
         "test_connection": conf.get("core", "test_connection", 
fallback="Disabled"),
         "dashboard_alert": DASHBOARD_UIALERTS,
+        "show_external_log_redirect": task_log_reader.supports_external_link,
+        "external_log_name": getattr(task_log_reader.log_handler, "log_name", 
None),
     }
 
     config.update({key: value for key, value in additional_config.items()})
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index ec6efb8c19d..b67514b7de3 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1228,6 +1228,33 @@ export const UseTaskInstanceServiceGetLogKeyFn = (
   useTaskInstanceServiceGetLogKey,
   ...(queryKey ?? [{ accept, dagId, dagRunId, fullContent, mapIndex, taskId, 
token, tryNumber }]),
 ];
+export type TaskInstanceServiceGetExternalLogUrlDefaultResponse = Awaited<
+  ReturnType<typeof TaskInstanceService.getExternalLogUrl>
+>;
+export type TaskInstanceServiceGetExternalLogUrlQueryResult<
+  TData = TaskInstanceServiceGetExternalLogUrlDefaultResponse,
+  TError = unknown,
+> = UseQueryResult<TData, TError>;
+export const useTaskInstanceServiceGetExternalLogUrlKey = 
"TaskInstanceServiceGetExternalLogUrl";
+export const UseTaskInstanceServiceGetExternalLogUrlKeyFn = (
+  {
+    dagId,
+    dagRunId,
+    mapIndex,
+    taskId,
+    tryNumber,
+  }: {
+    dagId: string;
+    dagRunId: string;
+    mapIndex?: number;
+    taskId: string;
+    tryNumber: number;
+  },
+  queryKey?: Array<unknown>,
+) => [
+  useTaskInstanceServiceGetExternalLogUrlKey,
+  ...(queryKey ?? [{ dagId, dagRunId, mapIndex, taskId, tryNumber }]),
+];
 export type ImportErrorServiceGetImportErrorDefaultResponse = Awaited<
   ReturnType<typeof ImportErrorService.getImportError>
 >;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 29323892621..95f1377e712 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1697,6 +1697,44 @@ export const ensureUseTaskInstanceServiceGetLogData = (
         tryNumber,
       }),
   });
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const ensureUseTaskInstanceServiceGetExternalLogUrlData = (
+  queryClient: QueryClient,
+  {
+    dagId,
+    dagRunId,
+    mapIndex,
+    taskId,
+    tryNumber,
+  }: {
+    dagId: string;
+    dagRunId: string;
+    mapIndex?: number;
+    taskId: string;
+    tryNumber: number;
+  },
+) =>
+  queryClient.ensureQueryData({
+    queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn({
+      dagId,
+      dagRunId,
+      mapIndex,
+      taskId,
+      tryNumber,
+    }),
+    queryFn: () => TaskInstanceService.getExternalLogUrl({ dagId, dagRunId, 
mapIndex, taskId, tryNumber }),
+  });
 /**
  * Get Import Error
  * Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index d9328eab70b..4cf302b0e5e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1697,6 +1697,44 @@ export const prefetchUseTaskInstanceServiceGetLog = (
         tryNumber,
       }),
   });
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const prefetchUseTaskInstanceServiceGetExternalLogUrl = (
+  queryClient: QueryClient,
+  {
+    dagId,
+    dagRunId,
+    mapIndex,
+    taskId,
+    tryNumber,
+  }: {
+    dagId: string;
+    dagRunId: string;
+    mapIndex?: number;
+    taskId: string;
+    tryNumber: number;
+  },
+) =>
+  queryClient.prefetchQuery({
+    queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn({
+      dagId,
+      dagRunId,
+      mapIndex,
+      taskId,
+      tryNumber,
+    }),
+    queryFn: () => TaskInstanceService.getExternalLogUrl({ dagId, dagRunId, 
mapIndex, taskId, tryNumber }),
+  });
 /**
  * Get Import Error
  * Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index c05706beec8..5b3b77bb886 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2018,6 +2018,48 @@ export const useTaskInstanceServiceGetLog = <
       }) as TData,
     ...options,
   });
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const useTaskInstanceServiceGetExternalLogUrl = <
+  TData = Common.TaskInstanceServiceGetExternalLogUrlDefaultResponse,
+  TError = unknown,
+  TQueryKey extends Array<unknown> = unknown[],
+>(
+  {
+    dagId,
+    dagRunId,
+    mapIndex,
+    taskId,
+    tryNumber,
+  }: {
+    dagId: string;
+    dagRunId: string;
+    mapIndex?: number;
+    taskId: string;
+    tryNumber: number;
+  },
+  queryKey?: TQueryKey,
+  options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+  useQuery<TData, TError>({
+    queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn(
+      { dagId, dagRunId, mapIndex, taskId, tryNumber },
+      queryKey,
+    ),
+    queryFn: () =>
+      TaskInstanceService.getExternalLogUrl({ dagId, dagRunId, mapIndex, 
taskId, tryNumber }) as TData,
+    ...options,
+  });
 /**
  * Get Import Error
  * Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 3180bd87b93..b0033f55421 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1995,6 +1995,48 @@ export const useTaskInstanceServiceGetLogSuspense = <
       }) as TData,
     ...options,
   });
+/**
+ * Get External Log Url
+ * Get external log URL for a specific task instance.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.taskId
+ * @param data.tryNumber
+ * @param data.mapIndex
+ * @returns ExternalLogUrlResponse Successful Response
+ * @throws ApiError
+ */
+export const useTaskInstanceServiceGetExternalLogUrlSuspense = <
+  TData = Common.TaskInstanceServiceGetExternalLogUrlDefaultResponse,
+  TError = unknown,
+  TQueryKey extends Array<unknown> = unknown[],
+>(
+  {
+    dagId,
+    dagRunId,
+    mapIndex,
+    taskId,
+    tryNumber,
+  }: {
+    dagId: string;
+    dagRunId: string;
+    mapIndex?: number;
+    taskId: string;
+    tryNumber: number;
+  },
+  queryKey?: TQueryKey,
+  options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+  useSuspenseQuery<TData, TError>({
+    queryKey: Common.UseTaskInstanceServiceGetExternalLogUrlKeyFn(
+      { dagId, dagRunId, mapIndex, taskId, tryNumber },
+      queryKey,
+    ),
+    queryFn: () =>
+      TaskInstanceService.getExternalLogUrl({ dagId, dagRunId, mapIndex, 
taskId, tryNumber }) as TData,
+    ...options,
+  });
 /**
  * Get Import Error
  * Get an import error.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 142cc6db7b5..55bac5fa04f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -3046,6 +3046,19 @@ export const $EventLogResponse = {
   description: "Event Log Response.",
 } as const;
 
+export const $ExternalLogUrlResponse = {
+  properties: {
+    url: {
+      type: "string",
+      title: "Url",
+    },
+  },
+  type: "object",
+  required: ["url"],
+  title: "ExternalLogUrlResponse",
+  description: "Response for the external log URL endpoint.",
+} as const;
+
 export const $ExtraLinkCollectionResponse = {
   properties: {
     extra_links: {
@@ -5866,6 +5879,21 @@ export const $ConfigResponse = {
       type: "array",
       title: "Dashboard Alert",
     },
+    show_external_log_redirect: {
+      type: "boolean",
+      title: "Show External Log Redirect",
+    },
+    external_log_name: {
+      anyOf: [
+        {
+          type: "string",
+        },
+        {
+          type: "null",
+        },
+      ],
+      title: "External Log Name",
+    },
   },
   type: "object",
   required: [
@@ -5886,6 +5914,7 @@ export const $ConfigResponse = {
     "audit_view_included_events",
     "test_connection",
     "dashboard_alert",
+    "show_external_log_redirect",
   ],
   title: "ConfigResponse",
   description: "configuration serializer.",
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index b9c97a08128..48e8694a800 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -146,6 +146,8 @@ import type {
   PatchTaskInstanceDryRun1Response,
   GetLogData,
   GetLogResponse,
+  GetExternalLogUrlData,
+  GetExternalLogUrlResponse,
   GetImportErrorData,
   GetImportErrorResponse,
   GetImportErrorsData,
@@ -2548,6 +2550,41 @@ export class TaskInstanceService {
       },
     });
   }
+
+  /**
+   * Get External Log Url
+   * Get external log URL for a specific task instance.
+   * @param data The data for the request.
+   * @param data.dagId
+   * @param data.dagRunId
+   * @param data.taskId
+   * @param data.tryNumber
+   * @param data.mapIndex
+   * @returns ExternalLogUrlResponse Successful Response
+   * @throws ApiError
+   */
+  public static getExternalLogUrl(data: GetExternalLogUrlData): 
CancelablePromise<GetExternalLogUrlResponse> {
+    return __request(OpenAPI, {
+      method: "GET",
+      url: 
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}",
+      path: {
+        dag_id: data.dagId,
+        dag_run_id: data.dagRunId,
+        task_id: data.taskId,
+        try_number: data.tryNumber,
+      },
+      query: {
+        map_index: data.mapIndex,
+      },
+      errors: {
+        400: "Bad Request",
+        401: "Unauthorized",
+        403: "Forbidden",
+        404: "Not Found",
+        422: "Validation Error",
+      },
+    });
+  }
 }
 
 export class ImportErrorService {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 6f3d2f0147b..39f89144f3e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -792,6 +792,13 @@ export type EventLogResponse = {
   extra: string | null;
 };
 
+/**
+ * Response for the external log URL endpoint.
+ */
+export type ExternalLogUrlResponse = {
+  url: string;
+};
+
 /**
  * Extra Links Response.
  */
@@ -1476,6 +1483,8 @@ export type ConfigResponse = {
   audit_view_included_events: string;
   test_connection: string;
   dashboard_alert: Array<UIAlert>;
+  show_external_log_redirect: boolean;
+  external_log_name?: string | null;
 };
 
 /**
@@ -2391,6 +2400,16 @@ export type GetLogData = {
 
 export type GetLogResponse = TaskInstancesLogResponse;
 
+export type GetExternalLogUrlData = {
+  dagId: string;
+  dagRunId: string;
+  mapIndex?: number;
+  taskId: string;
+  tryNumber: number;
+};
+
+export type GetExternalLogUrlResponse = ExternalLogUrlResponse;
+
 export type GetImportErrorData = {
   importErrorId: number;
 };
@@ -4648,6 +4667,37 @@ export type $OpenApiTs = {
       };
     };
   };
+  
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/externalLogUrl/{try_number}":
 {
+    get: {
+      req: GetExternalLogUrlData;
+      res: {
+        /**
+         * Successful Response
+         */
+        200: ExternalLogUrlResponse;
+        /**
+         * Bad Request
+         */
+        400: HTTPExceptionResponse;
+        /**
+         * Unauthorized
+         */
+        401: HTTPExceptionResponse;
+        /**
+         * Forbidden
+         */
+        403: HTTPExceptionResponse;
+        /**
+         * Not Found
+         */
+        404: HTTPExceptionResponse;
+        /**
+         * Validation Error
+         */
+        422: HTTPValidationError;
+      };
+    };
+  };
   "/api/v2/importErrors/{import_error_id}": {
     get: {
       req: GetImportErrorData;
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 878f06bb77c..efad2007b4e 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
@@ -38,7 +38,7 @@ export const ExtraLinks = () => {
         {Object.entries(data.extra_links).map(([key, value], _) =>
           value === null ? undefined : (
             <Button asChild colorPalette="blue" key={key} variant="surface">
-              <a href={value} rel="noreferrer" target="_blank">
+              <a href={value} rel="noopener noreferrer" target="_blank">
                 {key}
               </a>
             </Button>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ExternalLogLink.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ExternalLogLink.tsx
new file mode 100644
index 00000000000..f8bf4632b14
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ExternalLogLink.tsx
@@ -0,0 +1,71 @@
+/*!
+ * 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 { Button, Link } from "@chakra-ui/react";
+import { useParams } from "react-router-dom";
+
+import { useTaskInstanceServiceGetExternalLogUrl } from "openapi/queries";
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+type Props = {
+  readonly externalLogName: string;
+  readonly taskInstance: TaskInstanceResponse;
+  readonly tryNumber: number;
+};
+
+export const ExternalLogLink = ({ externalLogName, taskInstance, tryNumber }: 
Props) => {
+  const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
+
+  const {
+    data: externalLogData,
+    error,
+    isLoading,
+  } = useTaskInstanceServiceGetExternalLogUrl(
+    {
+      dagId,
+      dagRunId: runId,
+      mapIndex: parseInt(mapIndex, 10),
+      taskId,
+      tryNumber,
+    },
+    undefined,
+    {
+      enabled: Boolean(taskInstance) && Boolean(tryNumber) && 
Boolean(externalLogName),
+      retry: false,
+    },
+  );
+
+  if (Boolean(error) || isLoading || externalLogData?.url === undefined) {
+    return undefined;
+  }
+
+  return (
+    <Link
+      as={Button}
+      color="fg.info"
+      fontWeight="bold"
+      href={externalLogData.url}
+      py="2"
+      rel="noopener noreferrer"
+      target="_blank"
+      textDecoration="underline"
+    >
+      View logs in {externalLogName} (attempt {tryNumber})
+    </Link>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index 931caf50d6e..5373b22e2c0 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -26,6 +26,7 @@ import { SearchParamsKeys } from "src/constants/searchParams";
 import { useConfig } from "src/queries/useConfig";
 import { useLogs } from "src/queries/useLogs";
 
+import { ExternalLogLink } from "./ExternalLogLink";
 import { TaskLogContent } from "./TaskLogContent";
 import { TaskLogHeader } from "./TaskLogHeader";
 
@@ -83,6 +84,9 @@ export const Logs = () => {
     tryNumber: tryNumber === 0 ? 1 : tryNumber,
   });
 
+  const externalLogName = useConfig("external_log_name") as string;
+  const showExternalLogRedirect = 
Boolean(useConfig("show_external_log_redirect"));
+
   return (
     <Box p={2}>
       <TaskLogHeader
@@ -94,6 +98,17 @@ export const Logs = () => {
         tryNumber={tryNumber}
         wrap={wrap}
       />
+      {showExternalLogRedirect && externalLogName && taskInstance ? (
+        tryNumber === undefined ? (
+          <p>No try number</p>
+        ) : (
+          <ExternalLogLink
+            externalLogName={externalLogName}
+            taskInstance={taskInstance}
+            tryNumber={tryNumber}
+          />
+        )
+      ) : undefined}
       <TaskLogContent
         error={error}
         isLoading={isLoading || isLoadingLogs}
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
index b391fc66492..d8908f68b94 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_log.py
@@ -397,3 +397,55 @@ class TestTaskInstancesLog:
         )
         assert response.status_code == 404
         assert response.json()["detail"] == "TaskInstance not found"
+
+    @pytest.mark.parametrize(
+        "supports_external_link, task_id, expected_status, expected_response, 
mock_external_url",
+        [
+            (
+                True,
+                "task_for_testing_log_endpoint",
+                200,
+                {"url": "https://external-logs.example.com/log/123"},
+                True,
+            ),
+            (
+                False,
+                "task_for_testing_log_endpoint",
+                400,
+                {"detail": "Task log handler does not support external logs."},
+                False,
+            ),
+            (True, "INVALID_TASK", 404, {"detail": "TaskInstance not found"}, 
False),
+        ],
+        ids=[
+            "external_links_supported_task_exists",
+            "external_links_not_supported",
+            "external_links_supported_task_not_found",
+        ],
+    )
+    def test_get_external_log_url(
+        self, supports_external_link, task_id, expected_status, 
expected_response, mock_external_url
+    ):
+        with (
+            mock.patch(
+                
"airflow.utils.log.log_reader.TaskLogReader.supports_external_link",
+                new_callable=mock.PropertyMock,
+                return_value=supports_external_link,
+            ),
+            
mock.patch("airflow.utils.log.log_reader.TaskLogReader.log_handler") as 
mock_log_handler,
+        ):
+            url = 
f"/dags/{self.DAG_ID}/dagRuns/{self.RUN_ID}/taskInstances/{task_id}/externalLogUrl/{self.TRY_NUMBER}"
+            if mock_external_url:
+                mock_log_handler.get_external_log_url.return_value = (
+                    "https://external-logs.example.com/log/123";
+                )
+
+            response = self.client.get(url)
+
+            if expected_status == 200:
+                mock_log_handler.get_external_log_url.assert_called_once()
+            else:
+                mock_log_handler.get_external_log_url.assert_not_called()
+
+            assert response.status_code == expected_status
+            assert response.json() == expected_response
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
index 399b8658df6..1228c505a27 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
@@ -40,6 +40,8 @@ mock_config_response = {
     "audit_view_included_events": "",
     "test_connection": "Disabled",
     "dashboard_alert": [],
+    "show_external_log_redirect": False,
+    "external_log_name": None,
 }
 
 
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index a44f3e4d8ee..2e0178680f6 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -483,6 +483,14 @@ class EventLogResponse(BaseModel):
     extra: Annotated[str | None, Field(title="Extra")] = None
 
 
+class ExternalLogUrlResponse(BaseModel):
+    """
+    Response for the external log URL endpoint.
+    """
+
+    url: Annotated[str, Field(title="Url")]
+
+
 class ExtraLinkCollectionResponse(BaseModel):
     """
     Extra Links Response.

Reply via email to