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 e9ad9104a02 Add copy button to logs (#61185)
e9ad9104a02 is described below

commit e9ad9104a02724a69ef0b0d6d5d6eb1a2965a061
Author: Brent Bovenzi <[email protected]>
AuthorDate: Fri Jan 30 11:24:10 2026 -0500

    Add copy button to logs (#61185)
    
    * Add ability to copy visible logs
    
    * Some cleanup and move to a lazyclipboard
---
 .../ui/src/components/renderStructuredLog.tsx      |  2 +-
 .../airflow/ui/src/components/ui/LazyClipboard.tsx | 47 +++++++++++
 .../src/airflow/ui/src/components/ui/index.ts      |  1 +
 .../ui/src/pages/TaskInstance/Logs/Logs.tsx        | 97 +++++++++-------------
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx |  6 +-
 .../src/pages/TaskInstance/Logs/TaskLogHeader.tsx  | 17 +++-
 6 files changed, 107 insertions(+), 63 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx 
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
index f810f57554b..33d7b4e4c54 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
@@ -245,7 +245,7 @@ const renderStructuredLogImpl = ({
       const stringifiedValue = val instanceof Object ? JSON.stringify(val) : 
val;
 
       if (renderingMode === "text") {
-        elements.push(`${key === "logger" ? "source" : 
key}=${stringifiedValue} `);
+        elements.push(` ${key === "logger" ? "source" : 
key}=${stringifiedValue} `);
       } else {
         elements.push(
           <React.Fragment key={`space_${key}`}> </React.Fragment>,
diff --git a/airflow-core/src/airflow/ui/src/components/ui/LazyClipboard.tsx 
b/airflow-core/src/airflow/ui/src/components/ui/LazyClipboard.tsx
new file mode 100644
index 00000000000..56f8763383a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/ui/LazyClipboard.tsx
@@ -0,0 +1,47 @@
+/*!
+ * 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 type { ButtonProps } from "@chakra-ui/react";
+import { IconButton } from "@chakra-ui/react";
+import * as React from "react";
+import { LuCheck, LuClipboard } from "react-icons/lu";
+
+type LazyClipboardProps = {
+  readonly getValue: () => string;
+} & ButtonProps;
+
+/** Clipboard button that lazily computes the value only when clicked */
+export const LazyClipboard = React.forwardRef<HTMLButtonElement, 
LazyClipboardProps>(
+  ({ getValue, ...props }, ref) => {
+    const [copied, setCopied] = React.useState(false);
+
+    const handleClick = () => {
+      const value = getValue();
+
+      void navigator.clipboard.writeText(value);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    };
+
+    return (
+      <IconButton onClick={handleClick} ref={ref} size="xs" variant="subtle" 
{...props}>
+        {copied ? <LuCheck /> : <LuClipboard />}
+      </IconButton>
+    );
+  },
+);
diff --git a/airflow-core/src/airflow/ui/src/components/ui/index.ts 
b/airflow-core/src/airflow/ui/src/components/ui/index.ts
index d7ce843b119..4adadca3731 100644
--- a/airflow-core/src/airflow/ui/src/components/ui/index.ts
+++ b/airflow-core/src/airflow/ui/src/components/ui/index.ts
@@ -34,3 +34,4 @@ export * from "./Checkbox";
 export * from "./ResetButton";
 export * from "./InputWithAddon";
 export * from "./ButtonGroupToggle";
+export * from "./LazyClipboard";
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 0ba5737c91a..37df6135463 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
@@ -32,8 +32,8 @@ import { useLogs } from "src/queries/useLogs";
 import { parseStreamingLogContent } from "src/utils/logs";
 
 import { ExternalLogLink } from "./ExternalLogLink";
-import { TaskLogContent } from "./TaskLogContent";
-import { TaskLogHeader } from "./TaskLogHeader";
+import { TaskLogContent, type TaskLogContentProps } from "./TaskLogContent";
+import { TaskLogHeader, type TaskLogHeaderProps } from "./TaskLogHeader";
 
 export const Logs = () => {
   const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
@@ -74,13 +74,9 @@ export const Logs = () => {
   const tryNumber = tryNumberParam === null ? taskInstance?.try_number : 
parseInt(tryNumberParam, 10);
 
   const defaultWrap = Boolean(useConfig("default_wrap"));
-  const defaultShowTimestamp = Boolean(true);
 
   const [wrap, setWrap] = useLocalStorage<boolean>("log_wrap", defaultWrap);
-  const [showTimestamp, setShowTimestamp] = useLocalStorage<boolean>(
-    "log_show_timestamp",
-    defaultShowTimestamp,
-  );
+  const [showTimestamp, setShowTimestamp] = 
useLocalStorage<boolean>("log_show_timestamp", true);
   const [showSource, setShowSource] = 
useLocalStorage<boolean>("log_show_source", false);
   const [fullscreen, setFullscreen] = useState(false);
   const [expanded, setExpanded] = useState(false);
@@ -101,9 +97,10 @@ export const Logs = () => {
     tryNumber,
   });
 
-  const downloadLogs = () => {
+  const getParsedLogs = () => {
     const lines = parseStreamingLogContent(fetchedData);
-    const parsedLines = lines.map((line) =>
+
+    return lines.map((line) =>
       renderStructuredLog({
         index: 0,
         logLevelFilters,
@@ -116,8 +113,12 @@ export const Logs = () => {
         translate,
       }),
     );
+  };
 
-    const logContent = parsedLines.join("\n");
+  const getLogString = () => getParsedLogs().join("\n");
+
+  const downloadLogs = () => {
+    const logContent = getLogString();
     const element = document.createElement("a");
 
     element.href = URL.createObjectURL(new Blob([logContent], { type: 
"text/plain" }));
@@ -147,24 +148,35 @@ export const Logs = () => {
   const externalLogName = useConfig("external_log_name") as string;
   const showExternalLogRedirect = 
Boolean(useConfig("show_external_log_redirect"));
 
+  const logHeaderProps: TaskLogHeaderProps = {
+    downloadLogs,
+    expanded,
+    getLogString,
+    onSelectTryNumber,
+    showSource,
+    showTimestamp,
+    sourceOptions: parsedData.sources,
+    taskInstance,
+    toggleExpanded,
+    toggleFullscreen,
+    toggleSource,
+    toggleTimestamp,
+    toggleWrap,
+    tryNumber,
+    wrap,
+  };
+
+  const logContentProps: TaskLogContentProps = {
+    error,
+    isLoading: isLoading || isLoadingLogs,
+    logError,
+    parsedLogs: parsedData.parsedLogs ?? [],
+    wrap,
+  };
+
   return (
     <Box display="flex" flexDirection="column" h="100%" p={2}>
-      <TaskLogHeader
-        downloadLogs={downloadLogs}
-        expanded={expanded}
-        onSelectTryNumber={onSelectTryNumber}
-        showSource={showSource}
-        showTimestamp={showTimestamp}
-        sourceOptions={parsedData.sources}
-        taskInstance={taskInstance}
-        toggleExpanded={toggleExpanded}
-        toggleFullscreen={toggleFullscreen}
-        toggleSource={toggleSource}
-        toggleTimestamp={toggleTimestamp}
-        toggleWrap={toggleWrap}
-        tryNumber={tryNumber}
-        wrap={wrap}
-      />
+      <TaskLogHeader {...logHeaderProps} />
       {showExternalLogRedirect && externalLogName && taskInstance ? (
         tryNumber === undefined ? (
           <p>{translate("logs.noTryNumber")}</p>
@@ -176,13 +188,7 @@ export const Logs = () => {
           />
         )
       ) : undefined}
-      <TaskLogContent
-        error={error}
-        isLoading={isLoading || isLoadingLogs}
-        logError={logError}
-        parsedLogs={parsedData.parsedLogs ?? []}
-        wrap={wrap}
-      />
+      <TaskLogContent {...logContentProps} />
       <Dialog.Root onOpenChange={onOpenChange} open={fullscreen} 
scrollBehavior="inside" size="full">
         {fullscreen ? (
           <Dialog.Content backdrop>
@@ -191,35 +197,14 @@ export const Logs = () => {
                 <Heading mb={2} size="xl">
                   {taskId}
                 </Heading>
-                <TaskLogHeader
-                  downloadLogs={downloadLogs}
-                  expanded={expanded}
-                  onSelectTryNumber={onSelectTryNumber}
-                  showSource={showSource}
-                  showTimestamp={showTimestamp}
-                  sourceOptions={parsedData.sources}
-                  taskInstance={taskInstance}
-                  toggleExpanded={toggleExpanded}
-                  toggleFullscreen={toggleFullscreen}
-                  toggleSource={toggleSource}
-                  toggleTimestamp={toggleTimestamp}
-                  toggleWrap={toggleWrap}
-                  tryNumber={tryNumber}
-                  wrap={wrap}
-                />
+                <TaskLogHeader {...logHeaderProps} />
               </Box>
             </Dialog.Header>
 
             <Dialog.CloseTrigger />
 
             <Dialog.Body display="flex" flexDirection="column">
-              <TaskLogContent
-                error={error}
-                isLoading={isLoading || isLoadingLogs}
-                logError={logError}
-                parsedLogs={parsedData.parsedLogs ?? []}
-                wrap={wrap}
-              />
+              <TaskLogContent {...logContentProps} />
             </Dialog.Body>
           </Dialog.Content>
         ) : undefined}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
index 68ae198b2a9..fc2db356be6 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
@@ -29,7 +29,7 @@ import { getMetaKey } from "src/utils";
 
 import { scrollToBottom, scrollToTop } from "./utils";
 
-type Props = {
+export type TaskLogContentProps = {
   readonly error: unknown;
   readonly isLoading: boolean;
   readonly logError: unknown;
@@ -79,7 +79,7 @@ const ScrollToButton = ({
   );
 };
 
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap 
}: Props) => {
+export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap 
}: TaskLogContentProps) => {
   const hash = location.hash.replace("#", "");
   const parentRef = useRef<HTMLDivElement | null>(null);
 
@@ -129,7 +129,6 @@ export const TaskLogContent = ({ error, isLoading, 
logError, parsedLogs, wrap }:
         data-testid="virtual-scroll-container"
         flexGrow={1}
         minHeight={0}
-        overflow="auto"
         position="relative"
         py={3}
         ref={parentRef}
@@ -141,6 +140,7 @@ export const TaskLogContent = ({ error, isLoading, 
logError, parsedLogs, wrap }:
           }}
           data-testid="virtualized-list"
           display="block"
+          overflow="auto"
           textWrap={wrap ? "pre" : "nowrap"}
           width="100%"
         >
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 1dfecda947a..2f6d2de8109 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
@@ -40,13 +40,15 @@ import { useSearchParams } from "react-router-dom";
 import type { TaskInstanceResponse } from "openapi/requests/types.gen";
 import { TaskTrySelect } from "src/components/TaskTrySelect";
 import { Menu, Select } from "src/components/ui";
+import { LazyClipboard } from "src/components/ui/LazyClipboard";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { defaultSystem } from "src/theme";
 import { type LogLevel, logLevelColorMapping, logLevelOptions } from 
"src/utils/logs";
 
-type Props = {
+export type TaskLogHeaderProps = {
   readonly downloadLogs?: () => void;
   readonly expanded?: boolean;
+  readonly getLogString: () => string;
   readonly isFullscreen?: boolean;
   readonly onSelectTryNumber: (tryNumber: number) => void;
   readonly showSource: boolean;
@@ -65,6 +67,7 @@ type Props = {
 export const TaskLogHeader = ({
   downloadLogs,
   expanded,
+  getLogString,
   isFullscreen = false,
   onSelectTryNumber,
   showSource,
@@ -78,8 +81,8 @@ export const TaskLogHeader = ({
   toggleWrap,
   tryNumber,
   wrap,
-}: Props) => {
-  const { t: translate } = useTranslation(["common", "dag"]);
+}: TaskLogHeaderProps) => {
+  const { t: translate } = useTranslation(["common", "dag", "components"]);
   const [searchParams, setSearchParams] = useSearchParams();
   const sources = searchParams.getAll(SearchParamsKeys.SOURCE);
   const logLevels = searchParams.getAll(SearchParamsKeys.LOG_LEVEL);
@@ -249,6 +252,14 @@ export const TaskLogHeader = ({
             </IconButton>
           )}
 
+          <LazyClipboard
+            aria-label={translate("components:clipboard.copy")}
+            getValue={getLogString}
+            size="md"
+            title={translate("components:clipboard.copy")}
+            variant="ghost"
+          />
+
           <IconButton
             aria-label={translate("download.download")}
             onClick={downloadLogs}

Reply via email to