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 0033fc2f2968c..e0da0bb4bd548 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 = (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( + , + ); + 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( + , + ); + 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( , 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 d71a1e8883ceb..ea22dc1bedd5f 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(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 : ""}`} > - - {"\u25B6"} - + {getGroupHeaderMarker(isExpanded)}{" "} {visibleSearchMatchIndices?.has(virtualRow.index) ? ( {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 72b10f4821735..93a5c2fafc17e 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 2c27549d90bf6..8c05efb8283de 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): 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 5ff63bb551199..d8ea24fd39373 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; diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx index 84af1d47be42f..19674a942c0e3 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 }); } });