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}