diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx index a8389611f2429..bac81ef906d01 100644 --- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx +++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx @@ -352,6 +352,7 @@ const renderStructuredLogImpl = ({ return ( { }, 10_000); }); +const makeClipboardData = () => { + const store = new Map(); + + return { + getData: (type: string) => store.get(type) ?? "", + setData: (type: string, value: string) => store.set(type, value), + }; +}; + +const dispatchCopy = (clipboardData: ReturnType) => { + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + + Object.defineProperty(copyEvent, "clipboardData", { value: clipboardData }); + document.dispatchEvent(copyEvent); + + return copyEvent; +}; + const findRow = (text: string) => { const container = screen.getByTestId("virtual-scroll-container"); @@ -456,6 +474,101 @@ const withFakeSelection = (selection: Selection, callback: () => T): T => { return result; }; +describe("Copy across virtualized rows", () => { + it("rebuilds a removed grouped row from log data and strips line numbers", async () => { + render( + , + ); + await waitForLogs(); + + fireEvent.click(screen.getByTestId("summary-Pre Execute")); + await waitFor(() => expect(screen.getByText(/DAG bundles loaded/iu)).toBeInTheDocument()); + + const firstRow = findRow("Task started"); + const middleRow = findRow("DAG bundles loaded"); + const lastRow = findRow("Done. Returned value was: None"); + + expect(firstRow).toBeDefined(); + expect(middleRow).toBeDefined(); + expect(lastRow).toBeDefined(); + + middleRow.remove(); + + const range = document.createRange(); + + range.setStart(firstRow, 0); + range.setEnd(lastRow, lastRow.childNodes.length); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 1 } as unknown as Selection; + const clipboardData = makeClipboardData(); + const copyEvent = withFakeSelection(selection, () => dispatchCopy(clipboardData)); + + expect(copyEvent.defaultPrevented).toBe(true); + + const lines = clipboardData.getData("text/plain").split("\n"); + + expect(lines[0]).not.toMatch(/^\d/u); + expect(lines[0]).toContain("Task started"); + expect(lines).toContainEqual( + expect.stringMatching(/^\[.+\] INFO - DAG bundles loaded: dags-folder, example_dags$/u), + ); + }); + + it("rebuilds a removed ungrouped row from log data", async () => { + render( + , + ); + await waitForLogs(); + + const firstRow = findRow("Log message source details"); + const middleRow = findRow("Task started"); + const lastRow = findRow("Done. Returned value was: None"); + + expect(firstRow).toBeDefined(); + expect(middleRow).toBeDefined(); + expect(lastRow).toBeDefined(); + + middleRow.remove(); + + const range = document.createRange(); + + range.setStart(firstRow, 0); + range.setEnd(lastRow, lastRow.childNodes.length); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 1 } as unknown as Selection; + const clipboardData = makeClipboardData(); + const copyEvent = withFakeSelection(selection, () => dispatchCopy(clipboardData)); + + expect(copyEvent.defaultPrevented).toBe(true); + expect(clipboardData.getData("text/plain").split("\n")).toContainEqual( + expect.stringMatching(/^\[.+\] INFO - Task started$/u), + ); + }); + + it("leaves single-row selections to native copy", async () => { + render( + , + ); + await waitForLogs(); + + const row = findRow("Task started"); + + expect(row).toBeDefined(); + + const range = document.createRange(); + + range.setStart(row, 0); + range.setEnd(row, row.childNodes.length); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 1 } as unknown as Selection; + const clipboardData = makeClipboardData(); + const copyEvent = withFakeSelection(selection, () => dispatchCopy(clipboardData)); + + expect(copyEvent.defaultPrevented).toBe(false); + expect(clipboardData.getData("text/plain")).toBe(""); + }); +}); + describe("Selection pinning across scrolling", () => { it("keeps the selection-anchor row mounted after scrolling it out of the render window", async () => { render( 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 b720c081ed5ee..d68084e84b892 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,12 @@ import type { ParsedLogEntry } from "src/queries/useLogs"; import { HighlightedText } from "./HighlightedText"; import { ScrollToButton } from "./ScrollToButton"; -import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; +import { + extractSelectedLogText, + getEntryText, + getSelectionPinnedRows, + mergePinnedIndexes, +} from "./logSelection"; import { useLogGroups } from "./useLogGroups"; import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } from "./utils"; @@ -125,6 +130,36 @@ export const TaskLogContent = ({ return () => document.removeEventListener("selectionchange", handleSelectionChange); }, []); + useEffect(() => { + const handleCopy = (event: ClipboardEvent) => { + const container = parentRef.current; + const selection = document.getSelection(); + + if (!container || !selection || !event.clipboardData) { + return; + } + const text = extractSelectedLogText({ + container, + getRowText: (index) => { + const entry = visibleItems[index]?.entry; + + return entry ? getEntryText(entry) : ""; + }, + selection, + }); + + if (text === undefined) { + return; + } + event.preventDefault(); + event.clipboardData.setData("text/plain", text); + }; + + document.addEventListener("copy", handleCopy); + + return () => document.removeEventListener("copy", handleCopy); + }, [visibleItems]); + useLayoutEffect(() => { if (visibleItems.length === 0) { return; diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts index b359f1d1bd205..ceef008c2be6a 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts @@ -16,9 +16,16 @@ * specific language governing permissions and limitations * under the License. */ +import { createElement } from "react"; import { afterEach, describe, expect, it } from "vitest"; -import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; +import { + extractSelectedLogText, + getEntryText, + getSelectionPinnedRows, + getSelectionRowRange, + mergePinnedIndexes, +} from "./logSelection"; const buildLogContainer = (rows: Array<{ index: number; text: string }>): HTMLElement => { const container = document.createElement("div"); @@ -65,6 +72,72 @@ afterEach(() => { document.body.innerHTML = ""; }); +describe("getSelectionRowRange", () => { + it("returns the row range when both boundaries are inside log rows", () => { + const container = buildLogContainer([ + { index: 2, text: "line 2" }, + { index: 7, text: "line 7" }, + ]); + const selection = selectBetween({ + end: getRowTextNode(container, 7), + endOffset: 3, + start: getRowTextNode(container, 2), + startOffset: 1, + }); + + expect(getSelectionRowRange(selection, container)).toEqual({ end: 7, start: 2 }); + }); + + it("returns undefined for a collapsed selection", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + const node = getRowTextNode(container, 0); + + expect( + getSelectionRowRange( + selectBetween({ end: node, endOffset: 2, start: node, startOffset: 2 }), + container, + ), + ).toBeUndefined(); + }); + + it("returns undefined when a boundary is outside the container", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + const outside = document.createElement("div"); + + outside.setAttribute("data-index", "99"); + outside.textContent = "not a log line"; + document.body.append(outside); + + const selection = selectBetween({ + end: getRowTextNode(container, 0), + endOffset: 3, + start: outside.firstChild as Node, + startOffset: 0, + }); + + expect(getSelectionRowRange(selection, container)).toBeUndefined(); + }); + + it("returns undefined when a boundary degraded to the container itself", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 1, text: "line 1" }, + ]); + const range = document.createRange(); + + range.setStart(container, 0); + range.setEnd(getRowTextNode(container, 1), 3); + + expect(getSelectionRowRange(makeSelection(range), container)).toBeUndefined(); + }); + + it("returns undefined for a null selection", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + + expect(getSelectionRowRange(null, container)).toBeUndefined(); + }); +}); + describe("getSelectionPinnedRows", () => { it("pins both rows when both boundaries are inside log rows", () => { const container = buildLogContainer([ @@ -145,3 +218,158 @@ describe("mergePinnedIndexes", () => { expect(mergePinnedIndexes([5, 6], [-1, 2, 99], 10)).toEqual([2, 5, 6]); }); }); + +describe("extractSelectedLogText", () => { + it("returns undefined for a selection within a single row", () => { + const container = buildLogContainer([{ index: 0, text: "hello world" }]); + const node = getRowTextNode(container, 0); + + expect( + extractSelectedLogText({ + container, + getRowText: () => "", + selection: selectBetween({ end: node, endOffset: 5, start: node, startOffset: 0 }), + }), + ).toBeUndefined(); + }); + + it("returns undefined when every selected row is mounted", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 1, text: "line 1" }, + { index: 2, text: "line 2" }, + ]); + + expect( + extractSelectedLogText({ + container, + getRowText: () => "", + selection: selectBetween({ + end: getRowTextNode(container, 2), + endOffset: 3, + start: getRowTextNode(container, 0), + startOffset: 0, + }), + }), + ).toBeUndefined(); + }); + + it("rebuilds unmounted middle rows from log data, keeping partial boundary rows", () => { + const container = buildLogContainer([ + { index: 10, text: "hello world" }, + { index: 11, text: "mounted middle" }, + { index: 40, text: "foo bar" }, + ]); + const selection = selectBetween({ + end: getRowTextNode(container, 40), + endOffset: 3, + start: getRowTextNode(container, 10), + startOffset: 6, + }); + + const text = extractSelectedLogText({ + container, + getRowText: (index) => `line ${index}`, + selection, + }); + + const middleLines = Array.from({ length: 29 }, (_, offset) => `line ${offset + 11}`); + + expect(text).toBe(["world", ...middleLines, "foo"].join("\n")); + }); + + it("excludes copy-excluded line-number elements from boundary rows", () => { + const container = document.createElement("div"); + const buildRow = (index: number, messageText: string) => { + const row = document.createElement("div"); + + row.setAttribute("data-index", String(index)); + + const lineNumberLink = document.createElement("a"); + + lineNumberLink.setAttribute("data-copy-exclude", ""); + lineNumberLink.textContent = String(index); + + const message = document.createElement("span"); + + message.textContent = messageText; + row.append(lineNumberLink, message); + container.append(row); + + return message; + }; + const firstMessage = buildRow(10, "hello world"); + + buildRow(11, "mounted middle"); + + const lastMessage = buildRow(40, "foo bar"); + + document.body.append(container); + + const selection = selectBetween({ + end: lastMessage.firstChild as Node, + endOffset: 3, + start: firstMessage.firstChild as Node, + startOffset: 6, + }); + + const text = extractSelectedLogText({ + container, + getRowText: (index) => `line ${index}`, + selection, + }); + + const middleLines = Array.from({ length: 29 }, (_, offset) => `line ${offset + 11}`); + + expect(text).toBe(["world", ...middleLines, "foo"].join("\n")); + }); + + it("returns undefined for multi-range selections", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 5, text: "line 5" }, + ]); + const range = document.createRange(); + + range.setStart(getRowTextNode(container, 0), 0); + range.setEnd(getRowTextNode(container, 5), 3); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 2 } as unknown as Selection; + + expect(extractSelectedLogText({ container, getRowText: () => "", selection })).toBeUndefined(); + }); + + it("returns undefined when the selection cannot be mapped to rows", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 5, text: "line 5" }, + ]); + const range = document.createRange(); + + range.setStart(container, 0); + range.setEnd(getRowTextNode(container, 5), 3); + + expect( + extractSelectedLogText({ container, getRowText: () => "", selection: makeSelection(range) }), + ).toBeUndefined(); + }); +}); + +describe("getEntryText", () => { + it("returns string elements directly (group headers)", () => { + expect(getEntryText({ element: "Pre Execute" })).toBe("Pre Execute"); + }); + + it("prefers getPlainText over innerText for rendered log lines", () => { + const entry = { + element: createElement("span", undefined, "jsx text"), + getPlainText: () => "canonical text", + }; + + expect(getEntryText(entry)).toBe("canonical text"); + }); + + it("falls back to innerText when getPlainText is absent", () => { + expect(getEntryText({ element: createElement("span", undefined, "jsx text") })).toBe("jsx text"); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts index 6b0c6afc689cb..b51175a7e56ce 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts @@ -16,6 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import innerText from "react-innertext"; + +import type { ParsedLogEntry } from "src/queries/useLogs"; + +type RowRange = { + end: number; + start: number; +}; /** * Map a DOM node inside the virtualized log list to the `data-index` of the @@ -33,6 +41,28 @@ export const getRowIndexForNode = (node: Node | null, container: HTMLElement): n return Number.isInteger(index) ? index : undefined; }; +/** + * Row-index range covered by the current text selection, if both selection + * boundaries sit inside log rows of `container`. + */ +export const getSelectionRowRange = ( + selection: Selection | null, + container: HTMLElement, +): RowRange | undefined => { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return undefined; + } + const range = selection.getRangeAt(0); + const start = getRowIndexForNode(range.startContainer, container); + const end = getRowIndexForNode(range.endContainer, container); + + if (start === undefined || end === undefined) { + return undefined; + } + + return start <= end ? { end, start } : { end: start, start: end }; +}; + /** * Row indexes to pin so the virtualizer keeps selection-boundary rows * mounted. Boundaries map independently (the drag focus may sit off the @@ -71,3 +101,108 @@ export const mergePinnedIndexes = ( return [...new Set([...validPins, ...defaultIndexes])].sort((first, second) => first - second); }; + +/** + * Canonical plain text of a parsed log entry for clipboard rebuilding: + * group headers are plain strings, log lines render through the download + * text pipeline, and the innerText fallback covers synthetic entries such + * as the TI-context preamble. + */ +export const getEntryText = (entry: ParsedLogEntry): string => { + if (typeof entry.element === "string") { + return entry.element; + } + if (entry.getPlainText) { + return entry.getPlainText(); + } + + return entry.element ? innerText(entry.element) : ""; +}; + +/** + * Range text with copy-excluded elements (the line-number links) removed. + * Native copy drops them via `user-select: none`, but programmatic + * `Range.toString()` ignores CSS, so strip them explicitly. + */ +const getRangeText = (range: Range): string => { + const fragment = range.cloneContents(); + + for (const element of fragment.querySelectorAll("[data-copy-exclude]")) { + element.remove(); + } + + return fragment.textContent; +}; + +type ExtractSelectedLogTextOptions = { + container: HTMLElement; + getRowText: (index: number) => string; + selection: Selection; +}; + +/** + * Rebuild the text of a multi-row selection from log data. Native copy + * serializes the DOM, so it silently drops selected rows the virtualizer has + * unmounted. Returns undefined when native copy is already exact (single row + * or every selected row mounted) or when the selection cannot be mapped to + * log rows. + */ +export const extractSelectedLogText = ({ + container, + getRowText, + selection, +}: ExtractSelectedLogTextOptions): string | undefined => { + // Firefox multi-range selections: rebuilding only range 0 would clobber the rest. + if (selection.rangeCount !== 1) { + return undefined; + } + const rowRange = getSelectionRowRange(selection, container); + + if (!rowRange || rowRange.start === rowRange.end) { + return undefined; + } + + const mountedIndexes = new Set( + [...container.querySelectorAll("[data-index]")].map((row) => Number(row.getAttribute("data-index"))), + ); + + let hasUnmountedRow = false; + + for (let index = rowRange.start + 1; index < rowRange.end; index += 1) { + if (!mountedIndexes.has(index)) { + hasUnmountedRow = true; + break; + } + } + + if (!hasUnmountedRow) { + return undefined; + } + + const firstRow = container.querySelector(`[data-index="${rowRange.start}"]`); + const lastRow = container.querySelector(`[data-index="${rowRange.end}"]`); + + if (!firstRow || !lastRow) { + return undefined; + } + + const range = selection.getRangeAt(0); + const firstPartial = document.createRange(); + + firstPartial.selectNodeContents(firstRow); + firstPartial.setStart(range.startContainer, range.startOffset); + + const lastPartial = document.createRange(); + + lastPartial.selectNodeContents(lastRow); + lastPartial.setEnd(range.endContainer, range.endOffset); + + const lines = [getRangeText(firstPartial)]; + + for (let index = rowRange.start + 1; index < rowRange.end; index += 1) { + lines.push(getRowText(index)); + } + lines.push(getRangeText(lastPartial)); + + return lines.join("\n"); +}; diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts b/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts new file mode 100644 index 0000000000000..2f56c48e60849 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts @@ -0,0 +1,58 @@ +/*! + * 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 { TFunction } from "i18next"; +import { describe, expect, it } from "vitest"; + +import type { StructuredLogMessage } from "openapi/requests/types.gen"; + +import { getLogLineText } from "./useLogs"; + +const translate = ((key: string) => key) as unknown as TFunction; + +describe("getLogLineText", () => { + it("renders a structured line with timestamp and level, stripping ANSI codes", () => { + const logMessage = { + event: "\u001B[31mfailed\u001B[0m to run", + level: "error", + timestamp: "2026-01-01T00:00:00Z", + } as StructuredLogMessage; + + expect(getLogLineText({ logMessage, showSource: false, showTimestamp: true, translate })).toBe( + "[2026-01-01T00:00:00Z] ERROR - failed to run", + ); + }); + + it("omits the timestamp when showTimestamp is false", () => { + const logMessage = { + event: "task done", + level: "info", + timestamp: "2026-01-01T00:00:00Z", + } as StructuredLogMessage; + + expect(getLogLineText({ logMessage, showSource: false, showTimestamp: false, translate })).toBe( + "INFO - task done", + ); + }); + + it("strips ANSI codes from plain string lines", () => { + expect(getLogLineText({ logMessage: "plain \u001B[32mok\u001B[0m line", translate })).toBe( + "plain ok line", + ); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx index 52f54e9263a7f..84af1d47be42f 100644 --- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx +++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx @@ -17,6 +17,7 @@ * under the License. */ import type { UseQueryOptions } from "@tanstack/react-query"; +import Anser from "anser"; import dayjs from "dayjs"; import type { TFunction } from "i18next"; import type { JSX } from "react"; @@ -24,7 +25,11 @@ import { useTranslation } from "react-i18next"; import innerText from "react-innertext"; import { useTaskInstanceServiceGetLog } from "openapi/queries"; -import type { TaskInstanceResponse, TaskInstancesLogResponse } from "openapi/requests/types.gen"; +import type { + StructuredLogMessage, + TaskInstanceResponse, + TaskInstancesLogResponse, +} from "openapi/requests/types.gen"; import { extractTIContext, renderStructuredLog, @@ -36,10 +41,47 @@ import { parseStreamingLogContent } from "src/utils/logs"; export type ParsedLogEntry = { element: JSX.Element | string | undefined; + getPlainText?: () => string; group?: { id: number; level: number; parentId?: number; type: "header" | "line" }; lineNumber?: number; }; +type GetLogLineTextOptions = { + logLevelFilters?: Array; + logMessage: string | StructuredLogMessage; + showSource?: boolean; + showTimestamp?: boolean; + sourceFilters?: Array; + translate: TFunction; +}; + +/** + * Plain-text rendering of a single log line — same pipeline as the log + * download, with ANSI escape codes stripped. Used to rebuild copied text for + * selected rows the virtualizer has unmounted. + */ +export const getLogLineText = ({ + logLevelFilters, + logMessage, + showSource, + showTimestamp, + sourceFilters, + translate, +}: GetLogLineTextOptions): string => + Anser.ansiToText( + renderStructuredLog({ + index: 0, + logLevelFilters, + logLink: "", + logMessage, + renderingMode: "text", + showSource, + showTimestamp, + sourceFilters, + translate, + }), + ); + type Props = { accept?: "*/*" | "application/json" | "application/x-ndjson"; dagId: string; @@ -117,6 +159,7 @@ const parseLogs = ({ translate, }), lineNumber: lineNumbers[index], + logMessage: datum, }; }) .filter(({ element }) => element !== ""); @@ -136,8 +179,10 @@ const parseLogs = ({ const result: Array = []; let nextGroupId = 0; - parsedLines.forEach(({ element, lineNumber }) => { + parsedLines.forEach(({ element, lineNumber, logMessage }) => { const text = innerText(element); + const getPlainText = () => + getLogLineText({ logLevelFilters, logMessage, showSource, showTimestamp, sourceFilters, translate }); if (text.includes("::group::")) { const groupName = text.split("::group::")[1] as string; @@ -167,11 +212,12 @@ const parseLogs = ({ if (groupStack.length > 0 && currentGroup) { result.push({ element, + getPlainText, group: { id: currentGroup.id, level: currentGroup.level, type: "line" }, lineNumber, }); } else { - result.push({ element, lineNumber }); + result.push({ element, getPlainText, lineNumber }); } });