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 28f00cc5830 Make copied task log text match the on-screen format 
(#71270)
28f00cc5830 is described below

commit 28f00cc58301806a43ad5cf3ad63f6b466548645
Author: Andrew Chang <[email protected]>
AuthorDate: Fri Aug 14 03:15:33 2026 +0800

    Make copied task log text match the on-screen format (#71270)
    
    * Make copied task log text match the on-screen format
    
    Users expect copying logs to yield exactly what the screen shows.
    The clipboard rebuild for rows the virtualizer unmounted previously
    emitted the raw download format, so one copy could mix two timestamp
    formats and drop the group expand marker. The group marker now also
    follows the expand state (collapsed / expanded) in both the DOM and
    the rebuilt text. Follow-up to review feedback on #71156; the log
    download keeps raw source timestamps.
    
    * Remove unnecessary comment
    
    * UI: Keep copied log timestamps aligned with the screen
    
    Timestamp formatting belongs at the selection reconstruction boundary so 
downloads retain source timestamps and log parsing stays independent of UI 
timezone state.
    
    * UI: Preserve existing task log download formatting
    
    The clipboard fix should not alter shared text rendering because downloaded 
logs use the same path.
---
 .../ui/src/pages/TaskInstance/Logs/Logs.test.tsx   | 76 ++++++++++++++++++++++
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 49 ++++++++++----
 .../pages/TaskInstance/Logs/logSelection.test.ts   | 14 +++-
 .../ui/src/pages/TaskInstance/Logs/logSelection.ts | 15 +++--
 .../ui/src/pages/TaskInstance/Logs/utils.ts        |  2 +
 .../src/airflow/ui/src/queries/useLogs.tsx         |  5 +-
 6 files changed, 142 insertions(+), 19 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index 0033fc2f296..e0da0bb4bd5 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -465,6 +465,16 @@ const findRow = (text: string) => {
   ) as HTMLElement;
 };
 
+const getRowCopyText = (row: HTMLElement) => {
+  const clone = row.cloneNode(true) as HTMLElement;
+
+  for (const element of clone.querySelectorAll("[data-copy-exclude]")) {
+    element.remove();
+  }
+
+  return clone.textContent;
+};
+
 const withFakeSelection = <T,>(selection: Selection, callback: () => T): T => {
   const getSelectionSpy = vi.spyOn(document, 
"getSelection").mockReturnValue(selection);
   const result = callback();
@@ -545,6 +555,72 @@ describe("Copy across virtualized rows", () => {
     );
   });
 
+  it("rebuilds middle rows with the exact text mounted rows show on screen", 
async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]}
 />,
+    );
+    await waitForLogs();
+
+    const firstRow = findRow("Log message source details");
+    const taskStartedRow = findRow("Task started");
+    const headerRow = findRow("Pre Execute");
+    const lastRow = findRow("Done. Returned value was: None");
+
+    const taskStartedScreenText = getRowCopyText(taskStartedRow);
+    const headerScreenText = getRowCopyText(headerRow);
+
+    expect(taskStartedScreenText).toMatch(/^\[\d{4}-\d{2}-\d{2} 
\d{2}:\d{2}:\d{2}\] INFO - Task started$/u);
+    expect(headerScreenText).toBe("▶ Pre Execute");
+
+    taskStartedRow.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();
+
+    withFakeSelection(selection, () => dispatchCopy(clipboardData));
+
+    const lines = clipboardData.getData("text/plain").split("\n");
+
+    expect(lines[0]).toBe("▶ Log message source details");
+    expect(lines).toContain(taskStartedScreenText);
+    expect(lines).toContain(headerScreenText);
+  });
+
+  it("copies the expanded marker for expanded group headers", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]}
 />,
+    );
+    await waitForLogs();
+
+    fireEvent.click(screen.getByTestId("summary-Pre Execute"));
+    await waitFor(() => expect(getRowCopyText(findRow("Pre Execute"))).toBe("▼ 
Pre Execute"));
+
+    const firstRow = findRow("Log message source details");
+    const headerRow = findRow("Pre Execute");
+    const taskStartedRow = findRow("Task started");
+    const lastRow = findRow("Done. Returned value was: None");
+
+    taskStartedRow.remove();
+    headerRow.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();
+
+    withFakeSelection(selection, () => dispatchCopy(clipboardData));
+
+    expect(clipboardData.getData("text/plain").split("\n")).toContain("▼ Pre 
Execute");
+  });
+
   it("leaves single-row selections to native copy", async () => {
     render(
       <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/ti_context"]}
 />,
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 d71a1e8883c..ea22dc1bedd 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
@@ -19,13 +19,18 @@
 import { Box, Code, VStack } from "@chakra-ui/react";
 import { defaultRangeExtractor, useVirtualizer } from 
"@tanstack/react-virtual";
 import type { Range as VirtualizerRange } from "@tanstack/react-virtual";
+import dayjs from "dayjs";
+import tz from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
 import { useLayoutEffect, useRef, useCallback, useEffect } from "react";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
 import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useTimezone } from "src/context/timezone";
 import { useShortcut } from "src/hooks/useShortcut";
 import type { ParsedLogEntry } from "src/queries/useLogs";
+import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils";
 
 import { HighlightedText } from "./HighlightedText";
 import { ScrollToButton } from "./ScrollToButton";
@@ -38,7 +43,16 @@ import {
   mergePinnedIndexes,
 } from "./logSelection";
 import { useLogGroups } from "./useLogGroups";
-import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } 
from "./utils";
+import {
+  getGroupHeaderMarker,
+  getHighlightColor,
+  isSelectionWithin,
+  scrollToBottom,
+  scrollToTop,
+} from "./utils";
+
+dayjs.extend(utc);
+dayjs.extend(tz);
 
 export type TaskLogContentProps = {
   readonly currentMatchLineIndex?: number;
@@ -68,6 +82,7 @@ export const TaskLogContent = ({
   searchQuery,
   wrap,
 }: TaskLogContentProps) => {
+  const { selectedTimezone } = useTimezone();
   const hash = location.hash.replace("#", "");
   const parentRef = useRef<HTMLDivElement | null>(null);
 
@@ -222,7 +237,25 @@ export const TaskLogContent = ({
         getRowText: (index) => {
           const entry = visibleItems[index]?.entry;
 
-          return entry ? getEntryText(entry) : "";
+          if (!entry) {
+            return "";
+          }
+          const entryText = getEntryText(entry, expandedGroups);
+
+          if (entry.timestamp === undefined || entry.timestamp === "") {
+            return entryText;
+          }
+          const rawTimestampPrefix = `[${entry.timestamp}] `;
+
+          if (!entryText.startsWith(rawTimestampPrefix)) {
+            return entryText;
+          }
+          const timestamp = dayjs(entry.timestamp);
+          const formattedTimestamp = timestamp.isValid()
+            ? timestamp.tz(selectedTimezone).format(DEFAULT_DATETIME_FORMAT)
+            : entry.timestamp;
+
+          return `[${formattedTimestamp}] 
${entryText.slice(rawTimestampPrefix.length)}`;
         },
         selection,
       });
@@ -237,7 +270,7 @@ export const TaskLogContent = ({
     document.addEventListener("copy", handleCopy);
 
     return () => document.removeEventListener("copy", handleCopy);
-  }, [visibleItems]);
+  }, [visibleItems, expandedGroups, selectedTimezone]);
 
   useLayoutEffect(() => {
     if (visibleItems.length === 0) {
@@ -368,15 +401,7 @@ export const TaskLogContent = ({
                       color="fg.info"
                       data-testid={`summary-${typeof entry.element === 
"string" ? entry.element : ""}`}
                     >
-                      <Box
-                        as="span"
-                        display="inline-block"
-                        mr={1}
-                        transform={isExpanded ? "rotate(90deg)" : 
"rotate(0deg)"}
-                        transition="transform 0.15s"
-                      >
-                        {"\u25B6"}
-                      </Box>
+                      {getGroupHeaderMarker(isExpanded)}{" "}
                       {visibleSearchMatchIndices?.has(virtualRow.index) ? (
                         <HighlightedText query={searchQuery}>
                           {typeof entry.element === "string" ? entry.element : 
undefined}
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 72b10f48217..93a5c2fafc1 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
@@ -545,7 +545,19 @@ describe("extractSelectedLogText", () => {
 });
 
 describe("getEntryText", () => {
-  it("returns string elements directly (group headers)", () => {
+  it("rebuilds collapsed group headers with the collapsed marker", () => {
+    expect(getEntryText({ element: "Pre Execute", group: { id: 0, level: 0, 
type: "header" } })).toBe(
+      "▶ Pre Execute",
+    );
+  });
+
+  it("rebuilds expanded group headers with the expanded marker", () => {
+    expect(
+      getEntryText({ element: "Pre Execute", group: { id: 0, level: 0, type: 
"header" } }, new Set([0])),
+    ).toBe("▼ Pre Execute");
+  });
+
+  it("returns non-header string elements directly", () => {
     expect(getEntryText({ element: "Pre Execute" })).toBe("Pre Execute");
   });
 
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 2c27549d90b..8c05efb8283 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
@@ -20,6 +20,8 @@ import innerText from "react-innertext";
 
 import type { ParsedLogEntry } from "src/queries/useLogs";
 
+import { getGroupHeaderMarker } from "./utils";
+
 type RowRange = {
   end: number;
   start: number;
@@ -167,13 +169,16 @@ export const mergePinnedIndexes = (
 
 /**
  * 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.
+ * group headers rebuild as the on-screen `▶/▼ name` form (marker follows
+ * the group's current expand state), log lines render through the
+ * plain-text pipeline, and the innerText fallback covers synthetic
+ * entries such as the TI-context preamble.
  */
-export const getEntryText = (entry: ParsedLogEntry): string => {
+export const getEntryText = (entry: ParsedLogEntry, expandedGroupIds?: 
ReadonlySet<number>): string => {
   if (typeof entry.element === "string") {
-    return entry.element;
+    return entry.group?.type === "header"
+      ? `${getGroupHeaderMarker(expandedGroupIds?.has(entry.group.id) ?? 
false)} ${entry.element}`
+      : entry.element;
   }
   if (entry.getPlainText) {
     return entry.getPlainText();
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
index 5ff63bb5511..d8ea24fd393 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
@@ -27,6 +27,8 @@ import {
 } from "src/components/renderStructuredLog";
 import { parseStreamingLogContent } from "src/utils/logs";
 
+export const getGroupHeaderMarker = (isExpanded: boolean): string => 
(isExpanded ? "▼" : "▶");
+
 type GetDownloadTextOptions = {
   fetchedData: TaskInstancesLogResponse | undefined;
   logLevelFilters: Array<string>;
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx 
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index 84af1d47be4..19674a942c0 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -44,6 +44,7 @@ export type ParsedLogEntry = {
   getPlainText?: () => string;
   group?: { id: number; level: number; parentId?: number; type: "header" | 
"line" };
   lineNumber?: number;
+  timestamp?: string;
 };
 
 type GetLogLineTextOptions = {
@@ -208,6 +209,7 @@ const parseLogs = ({
       }
 
       const currentGroup = groupStack[groupStack.length - 1];
+      const timestamp = typeof logMessage === "string" ? undefined : 
logMessage.timestamp;
 
       if (groupStack.length > 0 && currentGroup) {
         result.push({
@@ -215,9 +217,10 @@ const parseLogs = ({
           getPlainText,
           group: { id: currentGroup.id, level: currentGroup.level, type: 
"line" },
           lineNumber,
+          timestamp,
         });
       } else {
-        result.push({ element, getPlainText, lineNumber });
+        result.push({ element, getPlainText, lineNumber, timestamp });
       }
     });
 

Reply via email to