Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"]} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -68,6 +82,7 @@ export const TaskLogContent = ({
searchQuery,
wrap,
}: TaskLogContentProps) => {
const { selectedTimezone } = useTimezone();
const hash = location.hash.replace("#", "");
const parentRef = useRef<HTMLDivElement | null>(null);

Expand Down Expand Up @@ -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,
});
Expand All @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
5 changes: 4 additions & 1 deletion airflow-core/src/airflow/ui/src/queries/useLogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -208,16 +209,18 @@ const parseLogs = ({
}

const currentGroup = groupStack[groupStack.length - 1];
const timestamp = typeof logMessage === "string" ? undefined : logMessage.timestamp;

if (groupStack.length > 0 && currentGroup) {
result.push({
element,
getPlainText,
group: { id: currentGroup.id, level: currentGroup.level, type: "line" },
lineNumber,
timestamp,
});
} else {
result.push({ element, getPlainText, lineNumber });
result.push({ element, getPlainText, lineNumber, timestamp });
}
});

Expand Down
Loading