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

pierrejeambrun 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 a2ee0d80d69 UI - Download Tasks Logs button (#56771)
a2ee0d80d69 is described below

commit a2ee0d80d693890216694a571b11384c056fceb7
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Oct 20 15:22:41 2025 +0200

    UI - Download Tasks Logs button (#56771)
    
    * Download Tasks Logs
    
    * Update following code review
---
 .../airflow/ui/public/i18n/locales/en/common.json  |   5 +
 .../ui/src/components/renderStructuredLog.tsx      | 129 ++++++++++++++-------
 .../ui/src/pages/Dag/Overview/TaskLogPreview.tsx   |   6 +-
 .../ui/src/pages/TaskInstance/Logs/Logs.tsx        |  68 ++++++++---
 .../src/pages/TaskInstance/Logs/TaskLogHeader.tsx  |  17 +++
 .../src/airflow/ui/src/queries/useLogs.tsx         |   3 +-
 6 files changed, 168 insertions(+), 60 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index ac440354e9f..1958abbb085 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -78,6 +78,11 @@
     "githubRepo": "GitHub Repo",
     "restApiReference": "REST API Reference"
   },
+  "download": {
+    "download": "Download",
+    "hotkey": "d",
+    "tooltip": "Press {{hotkey}} to download logs"
+  },
   "duration": "Duration",
   "endDate": "End Date",
   "error": {
diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx 
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
index a5707cdb1ce..f810f57554b 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
@@ -47,6 +47,7 @@ type RenderStructuredLogProps = {
   logLevelFilters?: Array<string>;
   logLink: string;
   logMessage: string | StructuredLogMessage;
+  renderingMode?: "jsx" | "text";
   showSource?: boolean;
   showTimestamp?: boolean;
   sourceFilters?: Array<string>;
@@ -107,17 +108,22 @@ const addAnsiWithLinks = (line: string) => {
 
 const sourceFields = ["logger", "chan", "lineno", "filename", "loc"];
 
-export const renderStructuredLog = ({
+const renderStructuredLogImpl = ({
   index,
   logLevelFilters,
   logLink,
   logMessage,
+  renderingMode = "jsx",
   showSource = true,
   showTimestamp = true,
   sourceFilters,
   translate,
-}: RenderStructuredLogProps) => {
+}: RenderStructuredLogProps): JSX.Element | string => {
   if (typeof logMessage === "string") {
+    if (renderingMode === "text") {
+      return logMessage;
+    }
+
     return (
       <chakra.span key={index} lineHeight={1.5}>
         {addAnsiWithLinks(logMessage)}
@@ -147,22 +153,32 @@ export const renderStructuredLog = ({
   }
 
   if (Boolean(timestamp) && showTimestamp) {
-    elements.push("[", <Time datetime={timestamp} key={0} />, "] ");
+    if (renderingMode === "text") {
+      elements.push(`[${timestamp}] `);
+    } else {
+      elements.push("[", <Time datetime={timestamp} key={0} />, "] ");
+    }
   }
 
   if (typeof level === "string") {
-    elements.push(
-      <Code
-        colorPalette={level.toUpperCase() in LogLevel ? 
logLevelColorMapping[level as LogLevel] : undefined}
-        key={1}
-        lineHeight={1.5}
-        minH={0}
-        px={0}
-      >
-        {level.toUpperCase()}
-      </Code>,
-      " - ",
-    );
+    const formattedLevel = level.toUpperCase();
+
+    if (renderingMode === "text") {
+      elements.push(`${formattedLevel} - `);
+    } else {
+      elements.push(
+        <Code
+          colorPalette={level.toUpperCase() in LogLevel ? 
logLevelColorMapping[level as LogLevel] : undefined}
+          key={1}
+          lineHeight={1.5}
+          minH={0}
+          px={0}
+        >
+          {formattedLevel}
+        </Code>,
+        " - ",
+      );
+    }
   }
 
   const { error_detail: errorDetail, ...reStructured } = structured;
@@ -170,13 +186,23 @@ export const renderStructuredLog = ({
 
   if (errorDetail !== undefined) {
     details = (errorDetail as Array<ErrorDetail>).map((error) => {
-      const errorLines = error.frames.map((frame) => (
-        <chakra.p 
key={`frame-${frame.name}-${frame.filename}-${frame.lineno}`}>
-          {translate("components:logs.file")}{" "}
-          <chakra.span 
color="fg.info">{JSON.stringify(frame.filename)}</chakra.span>,{" "}
-          {translate("components:logs.location", { line: frame.lineno, name: 
frame.name })}
-        </chakra.p>
-      ));
+      const errorLines = error.frames.map((frame) => {
+        if (renderingMode === "text") {
+          return `    ${translate("components:logs.file")} ${frame.filename}, 
${translate("components:logs.location", { line: frame.lineno, name: frame.name 
})}\n`;
+        }
+
+        return (
+          <chakra.p 
key={`frame-${frame.name}-${frame.filename}-${frame.lineno}`}>
+            {translate("components:logs.file")}{" "}
+            <chakra.span 
color="fg.info">{JSON.stringify(frame.filename)}</chakra.span>,{" "}
+            {translate("components:logs.location", { line: frame.lineno, name: 
frame.name })}
+          </chakra.p>
+        );
+      });
+
+      if (renderingMode === "text") {
+        return `${error.exc_type}: ${error.exc_value}\n${(errorLines as 
Array<string>).join("")}`;
+      }
 
       return (
         <chakra.details key={error.exc_type} ms="20em" open={true}>
@@ -192,9 +218,13 @@ export const renderStructuredLog = ({
   }
 
   elements.push(
-    <chakra.span className="event" key={2} whiteSpace="pre-wrap">
-      {addAnsiWithLinks(event)}
-    </chakra.span>,
+    renderingMode === "text" ? (
+      event
+    ) : (
+      <chakra.span className="event" key={2} whiteSpace="pre-wrap">
+        {addAnsiWithLinks(event)}
+      </chakra.span>
+    ),
   );
 
   if (Object.hasOwn(reStructured, "filename") && Object.hasOwn(reStructured, 
"lineno")) {
@@ -211,27 +241,37 @@ export const renderStructuredLog = ({
       }
       const val = reStructured[key] as boolean | number | object | string | 
null;
 
-      elements.push(
-        <React.Fragment key={`space_${key}`}> </React.Fragment>,
-        <span data-key={key} key={`struct_${key}`}>
-          <chakra.span color="fg.info">{key === "logger" ? "source" : 
key}</chakra.span>=
-          <span data-value>
-            {
-              // Let strings, ints, etc through as is, but JSON stringify 
anything more complex
-              val instanceof Object ? JSON.stringify(val) : val
-            }
-          </span>
-        </span>,
-      );
+      // Let strings, ints, etc through as is, but JSON stringify anything 
more complex
+      const stringifiedValue = val instanceof Object ? JSON.stringify(val) : 
val;
+
+      if (renderingMode === "text") {
+        elements.push(`${key === "logger" ? "source" : 
key}=${stringifiedValue} `);
+      } else {
+        elements.push(
+          <React.Fragment key={`space_${key}`}> </React.Fragment>,
+          <span data-key={key} key={`struct_${key}`}>
+            <chakra.span color="fg.info">{key === "logger" ? "source" : 
key}</chakra.span>=
+            <span data-value>{stringifiedValue}</span>
+          </span>,
+        );
+      }
     }
   }
 
   elements.push(
-    <chakra.span className="event" key={3} whiteSpace="pre-wrap">
-      {details}
-    </chakra.span>,
+    renderingMode === "text" ? (
+      details
+    ) : (
+      <chakra.span className="event" key={3} whiteSpace="pre-wrap">
+        {details}
+      </chakra.span>
+    ),
   );
 
+  if (renderingMode === "text") {
+    return (elements as Array<string>).join("");
+  }
+
   return (
     <chakra.div display="flex" key={index} lineHeight={1.5}>
       <RouterLink
@@ -257,3 +297,12 @@ export const renderStructuredLog = ({
     </chakra.div>
   );
 };
+
+// Overloads for renderStructuredLog function for stick type safety
+type RenderStructuredLogOverloads = {
+  (props: { renderingMode: "jsx" } & Omit<RenderStructuredLogProps, 
"renderingMode">): JSX.Element | "";
+  (props: { renderingMode: "text" } & Omit<RenderStructuredLogProps, 
"renderingMode">): string;
+};
+
+export const renderStructuredLog: RenderStructuredLogOverloads =
+  renderStructuredLogImpl as unknown as RenderStructuredLogOverloads;
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
index 0c3809ca21d..b5ac3707ea5 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
@@ -39,7 +39,11 @@ export const TaskLogPreview = ({
   const { t: translate } = useTranslation("dag");
   const [isExpanded, setIsExpanded] = useState(false);
 
-  const { data, error, isLoading } = useLogs(
+  const {
+    error,
+    isLoading,
+    parsedData: data,
+  } = useLogs(
     {
       dagId: taskInstance.dag_id,
       limit: 100,
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 86678a308c6..6215cdabd15 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
@@ -24,10 +24,12 @@ import { useParams, useSearchParams } from 
"react-router-dom";
 import { useLocalStorage } from "usehooks-ts";
 
 import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
+import { renderStructuredLog } from "src/components/renderStructuredLog";
 import { Dialog } from "src/components/ui";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { useConfig } from "src/queries/useConfig";
 import { useLogs } from "src/queries/useLogs";
+import { parseStreamingLogContent } from "src/utils/logs";
 
 import { ExternalLogLink } from "./ExternalLogLink";
 import { TaskLogContent } from "./TaskLogContent";
@@ -83,6 +85,48 @@ export const Logs = () => {
   const [fullscreen, setFullscreen] = useState(false);
   const [expanded, setExpanded] = useState(false);
 
+  const {
+    error: logError,
+    fetchedData,
+    isLoading: isLoadingLogs,
+    parsedData,
+  } = useLogs({
+    dagId,
+    expanded,
+    logLevelFilters,
+    showSource,
+    showTimestamp,
+    sourceFilters,
+    taskInstance,
+    tryNumber,
+  });
+
+  const downloadLogs = () => {
+    const lines = parseStreamingLogContent(fetchedData);
+    const parsedLines = lines.map((line) =>
+      renderStructuredLog({
+        index: 0,
+        logLevelFilters,
+        logLink: "",
+        logMessage: line,
+        renderingMode: "text",
+        showSource,
+        showTimestamp,
+        sourceFilters,
+        translate,
+      }),
+    );
+
+    const logContent = parsedLines.join("\n");
+    const element = document.createElement("a");
+
+    element.href = URL.createObjectURL(new Blob([logContent], { type: 
"text/plain" }));
+    element.download = 
`logs_${taskInstance?.dag_id}_${taskInstance?.dag_run_id}_${taskInstance?.task_id}_${taskInstance?.map_index}_${taskInstance?.try_number}`;
+    document.body.append(element);
+    element.click();
+    element.remove();
+  };
+
   const toggleWrap = () => setWrap(!wrap);
   const toggleTimestamp = () => setShowTimestamp(!showTimestamp);
   const toggleSource = () => setShowSource(!showSource);
@@ -94,37 +138,24 @@ export const Logs = () => {
   useHotkeys("e", toggleExpanded);
   useHotkeys("t", toggleTimestamp);
   useHotkeys("s", toggleSource);
+  useHotkeys("d", downloadLogs);
 
   const onOpenChange = () => {
     setFullscreen(false);
   };
 
-  const {
-    data,
-    error: logError,
-    isLoading: isLoadingLogs,
-  } = useLogs({
-    dagId,
-    expanded,
-    logLevelFilters,
-    showSource,
-    showTimestamp,
-    sourceFilters,
-    taskInstance,
-    tryNumber,
-  });
-
   const externalLogName = useConfig("external_log_name") as string;
   const showExternalLogRedirect = 
Boolean(useConfig("show_external_log_redirect"));
 
   return (
     <Box display="flex" flexDirection="column" h="100%" p={2}>
       <TaskLogHeader
+        downloadLogs={downloadLogs}
         expanded={expanded}
         onSelectTryNumber={onSelectTryNumber}
         showSource={showSource}
         showTimestamp={showTimestamp}
-        sourceOptions={data.sources}
+        sourceOptions={parsedData.sources}
         taskInstance={taskInstance}
         toggleExpanded={toggleExpanded}
         toggleFullscreen={toggleFullscreen}
@@ -149,7 +180,7 @@ export const Logs = () => {
         error={error}
         isLoading={isLoading || isLoadingLogs}
         logError={logError}
-        parsedLogs={data.parsedLogs ?? []}
+        parsedLogs={parsedData.parsedLogs ?? []}
         wrap={wrap}
       />
       <Dialog.Root onOpenChange={onOpenChange} open={fullscreen} 
scrollBehavior="inside" size="full">
@@ -158,6 +189,7 @@ export const Logs = () => {
             <VStack alignItems="flex-start" gap={2}>
               <Heading size="xl">{taskId}</Heading>
               <TaskLogHeader
+                downloadLogs={downloadLogs}
                 expanded={expanded}
                 isFullscreen
                 onSelectTryNumber={onSelectTryNumber}
@@ -182,7 +214,7 @@ export const Logs = () => {
               error={error}
               isLoading={isLoading || isLoadingLogs}
               logError={logError}
-              parsedLogs={data.parsedLogs ?? []}
+              parsedLogs={parsedData.parsedLogs ?? []}
               wrap={wrap}
             />
           </Dialog.Body>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
index f99051d92a0..0d8d6c88e77 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
@@ -34,6 +34,7 @@ import {
   MdOutlineOpenInFull,
   MdSettings,
   MdWrapText,
+  MdOutlineFileDownload,
 } from "react-icons/md";
 import { useSearchParams } from "react-router-dom";
 
@@ -45,6 +46,7 @@ import { system } from "src/theme";
 import { type LogLevel, logLevelColorMapping, logLevelOptions } from 
"src/utils/logs";
 
 type Props = {
+  readonly downloadLogs?: () => void;
   readonly expanded?: boolean;
   readonly isFullscreen?: boolean;
   readonly onSelectTryNumber: (tryNumber: number) => void;
@@ -62,6 +64,7 @@ type Props = {
 };
 
 export const TaskLogHeader = ({
+  downloadLogs,
   expanded,
   isFullscreen = false,
   onSelectTryNumber,
@@ -255,6 +258,20 @@ export const TaskLogHeader = ({
               </IconButton>
             </Tooltip>
           )}
+
+          <Tooltip closeDelay={100} content={translate("download.tooltip", { 
hotkey: "d" })} openDelay={100}>
+            <IconButton
+              aria-label={translate("download.download")}
+              bg="bg.panel"
+              m={0}
+              onClick={downloadLogs}
+              px={4}
+              py={2}
+              variant="outline"
+            >
+              <MdOutlineFileDownload />
+            </IconButton>
+          </Tooltip>
         </HStack>
       </HStack>
     </Box>
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx 
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index f2390554ae6..24f3907c87a 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -90,6 +90,7 @@ const parseLogs = ({
           logLevelFilters,
           logLink,
           logMessage: datum,
+          renderingMode: "jsx",
           showSource,
           showTimestamp,
           sourceFilters,
@@ -249,5 +250,5 @@ export const useLogs = (
     tryNumber,
   });
 
-  return { data: parsedData, ...rest };
+  return { parsedData, ...rest, fetchedData: data };
 };

Reply via email to